* Update Styles.css Small update to the styles that bothered me, i noticed in the window/modal for calendar when editing a day the time icons had a mask that overlapped the icon. I simply added 'background-image: none' prop to it/ * Importing files bug I found a bug that wouldn't let me upload files in the library window during the documents tab, when a user selected a file, the code grabbed a reference to fileInput.files and immediately cleared the input value (fileInput.value = '') to allow for re-uploading the same file later. However, because fileInput.files is a live FileList tied directly to the DOM element, clearing the input inherently emptied our saved variable as well, resulting in lost file data. Note this error might be browser specific as it worked fine on Zen/Firefox but failed on Edge and chrome Fix use Array.From which copies the value into files instead of using refrences
3354 lines
177 KiB
JavaScript
3354 lines
177 KiB
JavaScript
// static/js/documentLibrary.js
|
|
/**
|
|
* Document Library — modal with Chats / Documents / Research / Archive tabs.
|
|
* Extracted from document.js to reduce file size.
|
|
*/
|
|
|
|
import uiModule from './ui.js';
|
|
import sessionModule from './sessions.js';
|
|
import spinnerModule from './spinner.js';
|
|
import markdownModule from './markdown.js';
|
|
import { makeWindowDraggable } from './windowDrag.js';
|
|
import { langIcon } from './langIcons.js';
|
|
import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|
|
|
// ── Injected references from documentModule ──
|
|
let API_BASE = '';
|
|
let _esc; // HTML-escape function
|
|
let _getDocs; // () => Map of open docs
|
|
let _isOpenFn; // () => boolean — is doc panel open
|
|
let _createDocument;
|
|
let _loadDocument;
|
|
let _switchToDoc;
|
|
let _openPanel;
|
|
let _addDocToTabs;
|
|
let _syncDocIndicator;
|
|
|
|
export function initLibrary(config) {
|
|
API_BASE = config.apiBase;
|
|
_esc = config.esc;
|
|
_getDocs = config.getDocs;
|
|
_isOpenFn = config.isOpen;
|
|
_createDocument = config.createDocument;
|
|
_loadDocument = config.loadDocument;
|
|
_switchToDoc = config.switchToDoc;
|
|
_openPanel = config.openPanel;
|
|
_addDocToTabs = config.addDocToTabs;
|
|
_syncDocIndicator = config.syncDocIndicator;
|
|
}
|
|
|
|
// ── Library state ──
|
|
let _libraryOpen = false;
|
|
// Track which tabs have already played their domino-in cascade so we only
|
|
// animate the *first* time content loads per page session — tab swaps and
|
|
// re-renders after that are instant.
|
|
const _libraryCascadedTabs = new Set();
|
|
function _maybeCascadeGrid(grid, tabKey) {
|
|
if (!grid || !tabKey || _libraryCascadedTabs.has(tabKey)) return;
|
|
_libraryCascadedTabs.add(tabKey);
|
|
grid.classList.add('doclib-just-opened');
|
|
setTimeout(() => grid.classList.remove('doclib-just-opened'), 900);
|
|
}
|
|
let _libraryDocs = [];
|
|
let _libraryTotal = 0;
|
|
let _libraryOffset = 0;
|
|
let _docsVisibleLimit = 20; // chunked reveal (matches the Chats tab's 20)
|
|
let _libraryLanguages = {};
|
|
let _librarySessionCount = 0;
|
|
let _libraryActiveLanguage = null;
|
|
let _librarySort = 'recent';
|
|
let _librarySearch = '';
|
|
let _librarySearchDebounce = null;
|
|
|
|
// Highlight the active search terms inside a plain string. Escapes first,
|
|
// then wraps each whitespace-separated term in <mark>. Multi-term, matching
|
|
// the backend's per-term search, so every word that matched is marked.
|
|
function _hlSearch(text) {
|
|
const esc = _esc(text || '');
|
|
const q = (_librarySearch || '').trim();
|
|
if (!q) return esc;
|
|
const toks = [...new Set(q.split(/\s+/).filter(Boolean))]
|
|
.sort((a, b) => b.length - a.length) // prefer longer matches
|
|
.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
if (!toks.length) return esc;
|
|
try {
|
|
return esc.replace(new RegExp(`(${toks.join('|')})`, 'gi'),
|
|
'<mark class="doclib-search-hl">$1</mark>');
|
|
} catch { return esc; }
|
|
}
|
|
let _libraryEscHandler = null;
|
|
let _librarySelectMode = false;
|
|
let _librarySelectedIds = new Set();
|
|
let _libraryImportMode = false;
|
|
let _libScrollBound = false; // infinite-scroll listener attached once
|
|
let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|
|
|
// ---- Library animation helpers ----
|
|
|
|
/** Collapse an expanded card */
|
|
function _collapseExpandedCard(card) {
|
|
const grid = card.closest('.doclib-grid');
|
|
const instant = card?.dataset?.spaceToggle === '1';
|
|
card.classList.remove('doclib-card-expanded');
|
|
// Release the height lock so grid returns to natural size
|
|
if (grid) {
|
|
grid.style.minHeight = '';
|
|
grid.style.maxHeight = '';
|
|
}
|
|
const reader = card.querySelector('.doclib-card-reader');
|
|
if (reader) reader.remove();
|
|
|
|
// Fade siblings back in
|
|
if (grid && !instant) {
|
|
const siblings = [...grid.querySelectorAll('.doclib-card')].filter(c => c !== card);
|
|
siblings.forEach(s => { s.style.opacity = '0'; });
|
|
requestAnimationFrame(() => {
|
|
siblings.forEach(s => {
|
|
s.style.transition = 'opacity 0.15s ease';
|
|
s.style.opacity = '1';
|
|
});
|
|
setTimeout(() => { siblings.forEach(s => { s.style.transition = ''; s.style.opacity = ''; }); }, 200);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fetch a chat's full history and serialize as plain-text transcript,
|
|
// then write to the clipboard. Same User: / Assistant: format the chat
|
|
// header's "Copy Chat" button uses, but works for any session ID — the
|
|
// library doesn't need the chat to be loaded in the UI first.
|
|
async function _copyChatById(sessionId) {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/history/${sessionId}`, { credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error(res.statusText);
|
|
const data = await res.json();
|
|
const history = Array.isArray(data) ? data : (data.history || []);
|
|
const lines = [];
|
|
for (const m of history) {
|
|
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
const label = m.role === 'user' ? 'User' : 'Assistant';
|
|
const body = (m.content || '')
|
|
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
|
.replace(/<think>[\s\S]*$/, '')
|
|
.trim();
|
|
if (body) lines.push(`${label}: ${body}`);
|
|
}
|
|
const text = lines.join('\n\n');
|
|
if (uiModule && uiModule.copyToClipboard) {
|
|
await uiModule.copyToClipboard(text);
|
|
} else {
|
|
await navigator.clipboard.writeText(text);
|
|
}
|
|
} catch (err) {
|
|
if (uiModule && uiModule.showError) uiModule.showError('Failed to copy chat');
|
|
}
|
|
}
|
|
|
|
// Long-press a list card to open its actions menu. `menuSelector` resolves
|
|
// the existing ••• button on the card; on hold we trigger its click so the
|
|
// dropdown opens in its usual spot. Moved finger >10px or release before
|
|
// 500ms cancels.
|
|
function _attachLongPressMenu(card, menuSelector) {
|
|
let hold = null;
|
|
let start = null;
|
|
const cancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; };
|
|
card.addEventListener('pointerdown', (e) => {
|
|
if (e.target.closest(menuSelector + ', .memory-select-cb, button')) return;
|
|
start = { x: e.clientX, y: e.clientY };
|
|
hold = setTimeout(() => {
|
|
hold = null;
|
|
card._suppressNextClick = true;
|
|
setTimeout(() => { card._suppressNextClick = false; }, 400);
|
|
if (navigator.vibrate) try { navigator.vibrate(15); } catch {}
|
|
const btn = card.querySelector(menuSelector);
|
|
if (btn) btn.click();
|
|
}, 500);
|
|
});
|
|
card.addEventListener('pointermove', (e) => {
|
|
if (!start) return;
|
|
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) cancel();
|
|
});
|
|
card.addEventListener('pointerup', cancel);
|
|
card.addEventListener('pointercancel', cancel);
|
|
}
|
|
|
|
// Inline icons used by the chats/archive/research dropdown rows. Match the
|
|
// ones used by the documents-tab card menu so the visual language stays
|
|
// consistent across tabs.
|
|
const _LIB_DD_ICONS = {
|
|
open: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
|
|
archive: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
|
|
restore: '<svg width="14" height="14" 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 9-9"/><polyline points="3 4 3 9 8 9"/></svg>',
|
|
delete: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>',
|
|
clone: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
|
|
copy: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
|
|
};
|
|
|
|
function _showLibDropdown(anchor, items, opts) {
|
|
opts = opts || {};
|
|
document.querySelectorAll('._lib-dd').forEach(dismissOrRemove);
|
|
const dd = document.createElement('div');
|
|
dd.className = 'dropdown session-dropdown-menu _lib-dd';
|
|
for (const item of items) {
|
|
const row = document.createElement('div');
|
|
row.className = 'dropdown-item-compact' + (item.danger ? ' dropdown-item-danger' : '');
|
|
const iconKey = item.icon || item.label.toLowerCase();
|
|
const iconSvg = _LIB_DD_ICONS[iconKey] || '';
|
|
row.innerHTML = (iconSvg ? '<span class="dropdown-icon">' + iconSvg + '</span>' : '') + '<span>' + item.label + '</span>';
|
|
row.addEventListener('click', (e) => { e.stopPropagation(); teardown(); item.action(); });
|
|
dd.appendChild(row);
|
|
}
|
|
if (typeof opts.onSelect === 'function') {
|
|
const sel = document.createElement('div');
|
|
sel.className = 'dropdown-item-compact';
|
|
sel.innerHTML =
|
|
'<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span></span>'
|
|
+ '<span>Select</span>';
|
|
sel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); opts.onSelect(); });
|
|
dd.appendChild(sel);
|
|
}
|
|
const cancel = document.createElement('div');
|
|
cancel.className = 'dropdown-item-compact dropdown-cancel-mobile';
|
|
cancel.innerHTML =
|
|
'<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>'
|
|
+ '<span>Cancel</span>';
|
|
cancel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); if (typeof opts.onCancel === 'function') opts.onCancel(); });
|
|
dd.appendChild(cancel);
|
|
document.body.appendChild(dd);
|
|
const rect = anchor.getBoundingClientRect();
|
|
dd.style.right = (window.innerWidth - rect.right) + 'px';
|
|
dd.style.top = (rect.bottom + 2) + 'px';
|
|
dd.style.display = 'block';
|
|
dd.style.zIndex = '100000';
|
|
requestAnimationFrame(() => {
|
|
const mr = dd.getBoundingClientRect();
|
|
if (mr.bottom > window.innerHeight - 8) {
|
|
dd.style.top = (rect.top - mr.height - 2) + 'px';
|
|
}
|
|
if (mr.left < 8) { dd.style.left = '8px'; dd.style.right = 'auto'; }
|
|
});
|
|
// Single idempotent teardown shared by every dismissal path (item click,
|
|
// outside click, swipe, and the Escape arbiter via registerMenuDismiss).
|
|
let _unreg = () => {};
|
|
const teardown = () => {
|
|
_unreg(); _unreg = () => {};
|
|
document.removeEventListener('click', close);
|
|
dd.remove();
|
|
};
|
|
const close = (e) => { if (!dd.contains(e.target)) teardown(); };
|
|
setTimeout(() => document.addEventListener('click', close), 0);
|
|
_unreg = registerMenuDismiss(teardown);
|
|
dd._dismiss = teardown; // let bulk removers (reopen sweep) tear down cleanly
|
|
|
|
// Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel — drag the
|
|
// popup down and release past the threshold to close. Below threshold,
|
|
// snap back. Vertical-only; horizontal flicks fall through to scrolling.
|
|
let _swipeStart = null;
|
|
let _swipeDy = 0;
|
|
dd.addEventListener('touchstart', (e) => {
|
|
if (e.touches.length !== 1) return;
|
|
_swipeStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
_swipeDy = 0;
|
|
dd.style.transition = '';
|
|
}, { passive: true });
|
|
dd.addEventListener('touchmove', (e) => {
|
|
if (!_swipeStart || e.touches.length !== 1) return;
|
|
const dx = e.touches[0].clientX - _swipeStart.x;
|
|
const dy = e.touches[0].clientY - _swipeStart.y;
|
|
if (Math.abs(dy) < Math.abs(dx)) { _swipeStart = null; return; }
|
|
if (dy > 0) {
|
|
_swipeDy = dy;
|
|
dd.style.transform = 'translateY(' + dy + 'px)';
|
|
dd.style.opacity = String(Math.max(0.3, 1 - dy / 240));
|
|
}
|
|
}, { passive: true });
|
|
dd.addEventListener('touchend', () => {
|
|
if (!_swipeStart) return;
|
|
_swipeStart = null;
|
|
if (_swipeDy > 60) {
|
|
dd.style.transition = 'transform 0.15s ease, opacity 0.15s ease';
|
|
dd.style.transform = 'translateY(120px)';
|
|
dd.style.opacity = '0';
|
|
// Unregister + drop the outside-click listener now; defer the DOM
|
|
// removal so the slide-out animation can play.
|
|
_unreg(); _unreg = () => {};
|
|
document.removeEventListener('click', close);
|
|
setTimeout(() => dd.remove(), 160);
|
|
} else {
|
|
dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
|
|
dd.style.transform = '';
|
|
dd.style.opacity = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- Document Library ----
|
|
|
|
function libraryRelativeTime(isoString) {
|
|
if (!isoString) return '';
|
|
const now = Date.now();
|
|
const then = new Date(isoString).getTime();
|
|
const diffS = Math.floor((now - then) / 1000);
|
|
if (diffS < 60) return 'just now';
|
|
const diffM = Math.floor(diffS / 60);
|
|
if (diffM < 60) return diffM + 'm ago';
|
|
const diffH = Math.floor(diffM / 60);
|
|
if (diffH < 24) return diffH + 'h ago';
|
|
const diffD = Math.floor(diffH / 24);
|
|
if (diffD === 1) return 'yesterday';
|
|
if (diffD < 14) return diffD + 'd ago';
|
|
const diffW = Math.floor(diffD / 7);
|
|
if (diffW < 8) return diffW + 'w ago';
|
|
return new Date(isoString).toLocaleDateString();
|
|
}
|
|
|
|
async function libraryFetch(append) {
|
|
if (!append) _libraryOffset = 0;
|
|
// Bump page size to the backend max (50) so fullscreen doesn't leave
|
|
// empty space below the loaded rows — same idea as emailLibrary's
|
|
// limit=100, but documents_library validates `le=50` so we have to
|
|
// cap at that. Auto-fill loop below picks up any remaining gap.
|
|
const params = new URLSearchParams({
|
|
sort: _librarySort,
|
|
offset: String(_libraryOffset),
|
|
limit: '50',
|
|
});
|
|
if (_librarySearch) params.set('search', _librarySearch);
|
|
if (_libraryActiveLanguage) params.set('language', _libraryActiveLanguage);
|
|
if (_libraryArchivedView) params.set('archived', 'true');
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/documents/library?${params}`);
|
|
if (!res.ok) throw new Error(res.statusText);
|
|
const data = await res.json();
|
|
|
|
if (append) {
|
|
_libraryDocs = _libraryDocs.concat(data.documents);
|
|
} else {
|
|
_libraryDocs = data.documents;
|
|
_docsVisibleLimit = 20; // reset chunk on a fresh load / search / sort
|
|
}
|
|
_libraryTotal = data.total;
|
|
_libraryLanguages = data.languages;
|
|
_librarySessionCount = data.session_count;
|
|
|
|
libraryRenderStats();
|
|
libraryRenderLangChips();
|
|
libraryRenderGrid();
|
|
libraryRenderLoadMore();
|
|
} catch (e) {
|
|
console.error('Library fetch error:', e);
|
|
}
|
|
}
|
|
|
|
function libraryRenderStats() {
|
|
const el = document.getElementById('doclib-stats');
|
|
if (!el) return;
|
|
const totalAll = Object.values(_libraryLanguages).reduce((a, b) => a + b, 0);
|
|
if (_librarySearch || _libraryActiveLanguage) {
|
|
el.textContent = `${_libraryTotal} of ${totalAll} document${totalAll !== 1 ? 's' : ''}`;
|
|
} else {
|
|
el.textContent = `${totalAll} document${totalAll !== 1 ? 's' : ''}`;
|
|
}
|
|
}
|
|
|
|
function libraryRenderLangChips() {
|
|
const wrap = document.getElementById('doclib-chips');
|
|
if (!wrap) return;
|
|
// Remove only language chip buttons, keep sort/select elements
|
|
wrap.querySelectorAll('.memory-cat-chip').forEach(c => c.remove());
|
|
const totalAll = Object.values(_libraryLanguages).reduce((a, b) => a + b, 0);
|
|
|
|
// Hide the "all (0)" chip + lang chips entirely when there are no docs.
|
|
if (totalAll === 0) return;
|
|
|
|
const allChip = document.createElement('button');
|
|
allChip.className = 'memory-cat-chip' + (!_libraryActiveLanguage ? ' active' : '');
|
|
allChip.textContent = `all (${totalAll})`;
|
|
allChip.addEventListener('click', () => {
|
|
if (_librarySelectMode) {
|
|
_libraryDocs.forEach(d => _librarySelectedIds.add(d.id));
|
|
libraryUpdateBulkCount();
|
|
const selectAllEl = document.getElementById('doclib-select-all');
|
|
if (selectAllEl) selectAllEl.checked = true;
|
|
libraryRenderGrid();
|
|
return;
|
|
}
|
|
_libraryActiveLanguage = null;
|
|
libraryFetch(false);
|
|
});
|
|
wrap.appendChild(allChip);
|
|
|
|
const sorted = Object.entries(_libraryLanguages).sort((a, b) => b[1] - a[1]);
|
|
for (const [lang, count] of sorted) {
|
|
const chip = document.createElement('button');
|
|
chip.className = 'memory-cat-chip' + (_libraryActiveLanguage === lang ? ' active' : '');
|
|
chip.textContent = `${lang} (${count})`;
|
|
chip.addEventListener('click', () => {
|
|
_libraryActiveLanguage = lang;
|
|
libraryFetch(false);
|
|
});
|
|
wrap.appendChild(chip);
|
|
}
|
|
}
|
|
|
|
function libraryRenderGrid() {
|
|
const grid = document.getElementById('doclib-grid');
|
|
if (!grid) return;
|
|
// An open card menu is mounted on <body> (to escape overflow clipping), so
|
|
// clearing the grid would orphan it; dismiss it first so its listener +
|
|
// Escape-stack entry go too.
|
|
document.querySelectorAll('.doclib-card-dropdown').forEach(dismissOrRemove);
|
|
grid.innerHTML = '';
|
|
// Drop any previous inline load-more — regenerated below alongside the list.
|
|
if (grid.parentElement) grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove());
|
|
|
|
if (_libraryDocs.length === 0) {
|
|
if (_librarySearch || _libraryActiveLanguage) {
|
|
grid.innerHTML = '<div class="doclib-empty">No documents match your search.</div>';
|
|
} else {
|
|
const _impIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin:0 4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
|
|
grid.innerHTML =
|
|
'<div class="doclib-empty" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' +
|
|
'<span>No documents yet</span>' +
|
|
'<span style="opacity:0.7;font-size:11px;">' +
|
|
'<a href="#" data-doclib-import style="color:var(--accent,var(--red));text-decoration:underline;">Import' + _impIco + '</a>' +
|
|
' · or create one in a session' +
|
|
'</span>' +
|
|
'</div>';
|
|
grid.querySelector('[data-doclib-import]')?.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
document.getElementById('doclib-import-file-btn')?.click();
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
_maybeCascadeGrid(grid, 'documents');
|
|
|
|
// Reveal in 20-at-a-time chunks (matches the Chats tab). The legacy
|
|
// server-pagination button is suppressed in libraryRenderLoadMore; this
|
|
// inline button is the single control.
|
|
const shown = _libraryDocs.slice(0, _docsVisibleLimit);
|
|
for (const doc of shown) {
|
|
grid.appendChild(libraryCreateCard(doc));
|
|
}
|
|
// Show a "Load more" while either more loaded docs remain to reveal, or
|
|
// more exist on the server beyond what we've fetched.
|
|
const shownCount = shown.length;
|
|
if (shownCount < _libraryTotal) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'doclib-load-more doclib-inline-load-more';
|
|
btn.id = 'doclib-docs-load-more';
|
|
btn.textContent = `Load more (${shownCount} of ${_libraryTotal})`;
|
|
btn.addEventListener('click', async () => {
|
|
_docsVisibleLimit += 20;
|
|
// Need more than we've fetched? pull the next server page first.
|
|
if (_docsVisibleLimit > _libraryDocs.length && _libraryDocs.length < _libraryTotal) {
|
|
_libraryOffset = _libraryDocs.length;
|
|
await libraryFetch(true); // appends + re-renders
|
|
} else {
|
|
libraryRenderGrid();
|
|
}
|
|
});
|
|
grid.parentElement.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
// Infinite scroll for the library (mobile + desktop), covering EVERY tab —
|
|
// Documents, Chats, Research, Archive all render a `.doclib-inline-load-more`
|
|
// button (regenerated fresh each render). A capture-phase scroll listener
|
|
// catches whichever element actually scrolls and, when the visible button
|
|
// nears the viewport bottom, clicks it — reusing each tab's own load logic.
|
|
// We mark a button once clicked so the SAME instance can't double-fire (the
|
|
// next render makes a fresh, unmarked one), which is safe for both the sync
|
|
// reveal tabs (Chats/Research) and the async fetch tabs (Documents/Archive).
|
|
if (!_libScrollBound) {
|
|
_libScrollBound = true;
|
|
let _tick = false;
|
|
const _maybeAutoLoad = () => {
|
|
_tick = false;
|
|
if (!_libraryOpen) return;
|
|
for (const btn of document.querySelectorAll('.doclib-inline-load-more')) {
|
|
if (btn.dataset.autoLoaded) continue;
|
|
if (!btn.offsetParent) continue; // inactive tab (hidden)
|
|
if (btn.getBoundingClientRect().top > window.innerHeight + 600) continue;
|
|
btn.dataset.autoLoaded = '1';
|
|
btn.click();
|
|
break; // one load per scroll tick
|
|
}
|
|
};
|
|
document.addEventListener('scroll', () => {
|
|
if (_tick) return;
|
|
_tick = true;
|
|
requestAnimationFrame(_maybeAutoLoad);
|
|
}, true);
|
|
}
|
|
|
|
function libraryCreateCard(doc) {
|
|
const card = document.createElement('div');
|
|
card.className = 'doclib-card memory-item';
|
|
card.dataset.docId = doc.id;
|
|
if (_librarySelectMode && _librarySelectedIds.has(doc.id)) {
|
|
card.classList.add('selected');
|
|
}
|
|
|
|
// Checkbox for select mode
|
|
if (_librarySelectMode) {
|
|
const cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.className = 'memory-select-cb';
|
|
cb.checked = _librarySelectedIds.has(doc.id);
|
|
cb.addEventListener('click', (e) => e.stopPropagation());
|
|
cb.addEventListener('change', () => {
|
|
libraryToggleSelectItem(doc.id);
|
|
card.classList.toggle('selected', _librarySelectedIds.has(doc.id));
|
|
const selectAllEl = document.getElementById('doclib-select-all');
|
|
if (selectAllEl) selectAllEl.checked = _libraryDocs.every(d => _librarySelectedIds.has(d.id));
|
|
});
|
|
card.appendChild(cb);
|
|
}
|
|
|
|
// Content wrapper
|
|
const content = document.createElement('div');
|
|
content.style.cssText = 'flex:1;min-width:0;padding-top:4px;';
|
|
|
|
// Title row with version badge
|
|
const titleRow = document.createElement('div');
|
|
titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;';
|
|
const titleEl = document.createElement('span');
|
|
titleEl.className = 'memory-item-title';
|
|
titleEl.style.cssText = 'flex:0 1 auto;min-width:0;';
|
|
// Language-specific icon next to the title (matches the document's type:
|
|
// markdown/csv/python/html/etc.). Falls back to the generic document icon
|
|
// when the language has no dedicated glyph.
|
|
const _GEN_DOC_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
|
|
const _langSvg = doc.language && doc.language !== 'text'
|
|
? langIcon(doc.language, 12, { style: 'vertical-align:-2px;margin-right:4px;opacity:0.55;flex-shrink:0;color:currentColor;' })
|
|
: '';
|
|
titleEl.innerHTML = (_langSvg || _GEN_DOC_ICON) + _hlSearch(doc.title || 'Untitled');
|
|
titleRow.appendChild(titleEl);
|
|
const verBadge = document.createElement('span');
|
|
verBadge.style.cssText = 'font-size:9px;padding:1px 6px;border-radius:8px;background:color-mix(in srgb, var(--red) 15%, transparent);border:1px solid color-mix(in srgb, var(--red) 40%, transparent);color:var(--red);flex-shrink:0;';
|
|
verBadge.textContent = 'v' + (doc.version_count || 1);
|
|
titleRow.appendChild(verBadge);
|
|
// Chevron pushed to the right end of the title row — collapsed
|
|
// shows nothing, expanded reveals a downward chevron so the user
|
|
// sees the card is open and can tap to close it.
|
|
const chevron = document.createElement('span');
|
|
chevron.className = 'doclib-card-chevron';
|
|
chevron.style.marginLeft = 'auto';
|
|
chevron.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
|
titleRow.appendChild(chevron);
|
|
content.appendChild(titleRow);
|
|
|
|
// Meta line: session → [lang-icon language] → time
|
|
const meta = document.createElement('div');
|
|
meta.className = 'memory-item-meta';
|
|
meta.style.cssText = 'font-size:10px;opacity:0.55;margin-top:2px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;';
|
|
const _esc = (s) => uiModule.esc(String(s || ''));
|
|
const pieces = [];
|
|
if (doc.session_name) pieces.push(`<span>${_esc(doc.session_name)}</span>`);
|
|
if (doc.language && doc.language !== 'text') {
|
|
const ic = langIcon(doc.language, 11, { style: 'vertical-align:-2px;flex-shrink:0;opacity:0.65;color:currentColor;' });
|
|
pieces.push(`<span style="display:inline-flex;align-items:center;gap:3px;">${ic}${_esc(doc.language)}</span>`);
|
|
}
|
|
pieces.push(`<span>${_esc(libraryRelativeTime(doc.updated_at))}</span>`);
|
|
meta.innerHTML = pieces.join('<span style="opacity:0.5;">\u00b7</span>');
|
|
// Strip the per-language icon from the meta line \u2014 it now sits next to the
|
|
// title above, so duplicating it here was redundant.
|
|
content.appendChild(meta);
|
|
card.appendChild(content);
|
|
|
|
// Header element (kept for expand/preview compatibility)
|
|
const header = document.createElement('div');
|
|
header.className = 'doclib-card-header';
|
|
header.style.display = 'none';
|
|
|
|
// Action buttons — "..." menu
|
|
const actionsWrap = document.createElement('div');
|
|
actionsWrap.className = 'memory-item-actions';
|
|
const menuWrap = document.createElement('span');
|
|
menuWrap.className = 'doclib-card-menu-wrap';
|
|
menuWrap.style.position = 'relative';
|
|
const menuBtn = document.createElement('button');
|
|
menuBtn.className = 'memory-item-btn';
|
|
menuBtn.title = 'Actions';
|
|
menuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
|
|
menuBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
// Mobile: the custom 5-item dropdown is too crowded — route through the
|
|
// shared _showLibDropdown with a small set (Open, Clone) plus Select +
|
|
// Cancel. Heavier actions (Archive, Delete, Export) live in bulk mode.
|
|
if (window.innerWidth <= 768) {
|
|
const items = [];
|
|
if (doc.session_id) items.push({ label: 'Open', action: () => libraryOpenInSession(doc) });
|
|
items.push({ label: 'Clone', action: () => libraryImportDocument(doc) });
|
|
_showLibDropdown(menuBtn, items, { onSelect: () => {
|
|
libraryEnterSelectMode();
|
|
_librarySelectedIds.add(doc.id);
|
|
libraryUpdateBulkCount();
|
|
libraryRenderGrid();
|
|
} });
|
|
return;
|
|
}
|
|
const dropdown = menuWrap.querySelector('.doclib-card-dropdown') || document.body.querySelector('.doclib-card-dropdown[data-owner="' + CSS.escape(doc.id) + '"]');
|
|
if (dropdown) {
|
|
const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body;
|
|
if (isOpen) {
|
|
hideCardDropdown();
|
|
} else {
|
|
// Position fixed on body to escape overflow clipping
|
|
const rect = menuBtn.getBoundingClientRect();
|
|
document.body.appendChild(dropdown);
|
|
dropdown.dataset.owner = doc.id;
|
|
dropdown.style.cssText = 'position:fixed;z-index:10000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;';
|
|
dropdown.style.top = (rect.bottom + 4) + 'px';
|
|
dropdown.style.left = 'auto';
|
|
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
|
// Clamp to viewport
|
|
requestAnimationFrame(() => {
|
|
const mr = dropdown.getBoundingClientRect();
|
|
if (mr.bottom > window.innerHeight - 8) dropdown.style.top = (rect.top - mr.height - 4) + 'px';
|
|
if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; }
|
|
});
|
|
// Close on outside click or Escape (the latter via the registry).
|
|
_cardDocClick = (ev) => {
|
|
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown();
|
|
};
|
|
setTimeout(() => document.addEventListener('click', _cardDocClick, true), 0);
|
|
_cardUnreg = registerMenuDismiss(hideCardDropdown);
|
|
}
|
|
}
|
|
});
|
|
menuWrap.appendChild(menuBtn);
|
|
|
|
// Dropdown menu
|
|
const dropdown = document.createElement('div');
|
|
dropdown.className = 'doclib-card-dropdown';
|
|
dropdown.style.cssText = 'display:none;position:absolute;top:100%;right:0;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;';
|
|
|
|
// Single close path for the card action dropdown, shared by the toggle
|
|
// button, the outside-click listener, every menu item, and the Escape
|
|
// arbiter (via registerMenuDismiss). Hides the menu, returns it to its
|
|
// wrapper, drops the outside-click listener, and unregisters from the
|
|
// Escape stack. Idempotent — safe to call from whichever path fires first.
|
|
let _cardUnreg = () => {};
|
|
let _cardDocClick = null;
|
|
function hideCardDropdown() {
|
|
_cardUnreg(); _cardUnreg = () => {};
|
|
if (_cardDocClick) { document.removeEventListener('click', _cardDocClick, true); _cardDocClick = null; }
|
|
dropdown.style.display = 'none';
|
|
if (dropdown.parentElement === document.body) menuWrap.appendChild(dropdown);
|
|
}
|
|
dropdown._dismiss = hideCardDropdown; // bulk removers tear down through this
|
|
|
|
const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
|
const _openIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
|
|
|
// Open
|
|
const openItem = document.createElement('button');
|
|
openItem.className = 'dropdown-item-compact';
|
|
openItem.style.cssText = 'background:none;border:none;width:100%;';
|
|
openItem.innerHTML = _di(_openIco) + '<span>Open</span>';
|
|
if (doc.session_id) {
|
|
openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); });
|
|
} else {
|
|
openItem.disabled = true;
|
|
openItem.style.opacity = '0.35';
|
|
openItem.title = 'Not linked to a session';
|
|
}
|
|
dropdown.appendChild(openItem);
|
|
|
|
// Clone
|
|
const _cloneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
const cloneItem = document.createElement('button');
|
|
cloneItem.className = 'dropdown-item-compact';
|
|
cloneItem.style.cssText = 'background:none;border:none;width:100%;';
|
|
cloneItem.innerHTML = _di(_cloneIco) + '<span>Clone</span>';
|
|
cloneItem.title = 'Clone to active session';
|
|
cloneItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryImportDocument(doc); });
|
|
dropdown.appendChild(cloneItem);
|
|
|
|
// Export
|
|
const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
|
const exportItem = document.createElement('button');
|
|
exportItem.className = 'dropdown-item-compact';
|
|
exportItem.style.cssText = 'background:none;border:none;width:100%;';
|
|
exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>';
|
|
exportItem.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
hideCardDropdown();
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
|
|
if (!res.ok) throw new Error('Failed');
|
|
const full = await res.json();
|
|
const extMap = { javascript: '.js', python: '.py', html: '.html', css: '.css', markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp', typescript: '.ts', ruby: '.rb', php: '.php', xml: '.xml', toml: '.toml', ini: '.ini' };
|
|
const ext = extMap[full.language] || '.txt';
|
|
const blob = new Blob([full.current_content || ''], { type: 'text/plain' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = (full.title || 'document') + ext;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
} catch { if (uiModule) uiModule.showError('Failed to export document'); }
|
|
});
|
|
dropdown.appendChild(exportItem);
|
|
|
|
// Archive / Restore — soft-archive a doc out of the main list, or bring it back.
|
|
const _archiveIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>';
|
|
const archiveItem = document.createElement('button');
|
|
archiveItem.className = 'dropdown-item-compact';
|
|
archiveItem.style.cssText = 'background:none;border:none;width:100%;';
|
|
archiveItem.innerHTML = _di(_archiveIco) + `<span>${_libraryArchivedView ? 'Restore' : 'Archive'}</span>`;
|
|
archiveItem.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)';
|
|
archiveItem.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
hideCardDropdown();
|
|
const toArchived = !_libraryArchivedView;
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error('failed');
|
|
// Drop it from the current view (it no longer belongs here) and refresh.
|
|
_libraryDocs = _libraryDocs.filter(d => d.id !== doc.id);
|
|
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
|
libraryRenderGrid();
|
|
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
|
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
|
|
});
|
|
dropdown.appendChild(archiveItem);
|
|
|
|
// Delete
|
|
const _deleteIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
|
const deleteItem = document.createElement('button');
|
|
deleteItem.className = 'dropdown-item-compact dropdown-item-danger';
|
|
deleteItem.style.cssText = 'background:none;border:none;width:100%;';
|
|
deleteItem.innerHTML = _di(_deleteIco) + '<span>Delete</span>';
|
|
deleteItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryDeleteSingle(doc.id, card); });
|
|
dropdown.appendChild(deleteItem);
|
|
|
|
menuWrap.appendChild(dropdown);
|
|
actionsWrap.appendChild(menuWrap);
|
|
card.appendChild(actionsWrap);
|
|
|
|
// Hidden header for expand/preview compatibility
|
|
card.appendChild(header);
|
|
|
|
// Inject library card hover styles once
|
|
if (!document.getElementById('doclib-card-styles')) {
|
|
const s = document.createElement('style');
|
|
s.id = 'doclib-card-styles';
|
|
s.textContent = `.doclib-card:hover .doclib-card-icon-btn{opacity:.4}.doclib-card-icon-btn:hover{opacity:1!important}.doclib-card-text-btn{background:none;border:1px solid var(--border);color:var(--fg-muted);font-size:10px;padding:3px 8px;border-radius:4px;cursor:pointer;transition:border-color .15s,color .15s}.doclib-card-text-btn:hover{border-color:var(--accent,var(--red));color:var(--accent,var(--red))}.doclib-card-text-btn-danger{border-color:var(--color-danger,#e06c75)!important;color:var(--color-danger,#e06c75)!important}.doclib-card-text-btn-danger:hover{border-color:#ff4d4d!important;color:#ff4d4d!important}.doclib-card-chevron{display:none;align-items:center;justify-content:center;align-self:center;opacity:0.6;transition:transform .15s ease;flex-shrink:0;height:14px;line-height:0}.doclib-card-expanded .doclib-card-chevron{display:inline-flex;transform:rotate(180deg)}.doclib-card-chevron svg{display:block}`;
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
// Preview — hidden by default, shown on expand
|
|
const preview = document.createElement('div');
|
|
preview.className = 'doclib-card-preview';
|
|
const pre = document.createElement('pre');
|
|
const code = document.createElement('code');
|
|
try {
|
|
if (doc.language && doc.language !== 'text' && window.hljs && !_librarySearch) {
|
|
code.innerHTML = window.hljs.highlight(doc.preview || '', { language: doc.language }).value;
|
|
} else if (_librarySearch) {
|
|
// While searching, highlight matched terms in the preview (plain
|
|
// text) rather than syntax-highlighting — the match is what matters.
|
|
code.innerHTML = _hlSearch(doc.preview || '');
|
|
} else {
|
|
code.textContent = doc.preview || '';
|
|
}
|
|
} catch {
|
|
code.textContent = doc.preview || '';
|
|
}
|
|
pre.appendChild(code);
|
|
preview.appendChild(pre);
|
|
|
|
// Expanded-only action bar — inside preview
|
|
const expandedActions = document.createElement('div');
|
|
expandedActions.className = 'doclib-card-expanded-actions';
|
|
|
|
const openBtn = document.createElement('button');
|
|
openBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
|
|
openBtn.innerHTML = '<svg 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:-1px;margin-right:3px;"><path d="M5 12h14M13 5l7 7-7 7"/></svg>Open';
|
|
if (doc.session_id) {
|
|
openBtn.title = 'Open in original session';
|
|
openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenInSession(doc); });
|
|
} else {
|
|
openBtn.disabled = true;
|
|
openBtn.style.opacity = '0.35';
|
|
openBtn.style.cursor = 'not-allowed';
|
|
openBtn.title = 'This document is not linked to a session';
|
|
}
|
|
|
|
const cloneBtn = document.createElement('button');
|
|
cloneBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
|
|
cloneBtn.innerHTML = '<svg 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:-1px;margin-right:3px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Clone';
|
|
cloneBtn.title = 'Clone — copy to active session';
|
|
cloneBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryImportDocument(doc); });
|
|
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger';
|
|
deleteBtn.innerHTML = '<svg 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:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete';
|
|
deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryDeleteSingle(doc.id, card); });
|
|
|
|
// Archive sits next to Delete on the LEFT — same lineup as the chat
|
|
// and research footers. Label flips to Restore inside the Archive view.
|
|
const archiveBtn = document.createElement('button');
|
|
archiveBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
|
|
archiveBtn.innerHTML = '<svg 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:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' + (_libraryArchivedView ? 'Restore' : 'Archive');
|
|
archiveBtn.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)';
|
|
archiveBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const toArchived = !_libraryArchivedView;
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error('failed');
|
|
_libraryDocs = _libraryDocs.filter(d => d.id !== doc.id);
|
|
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
|
libraryRenderGrid();
|
|
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
|
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
|
|
});
|
|
|
|
const leftGroup = document.createElement('div');
|
|
leftGroup.className = 'doclib-action-group';
|
|
const btnRow = document.createElement('div');
|
|
btnRow.className = 'doclib-action-btn-row';
|
|
// Export lives in the ⋮ menu — keep the footer uncrowded with Clone + Open.
|
|
btnRow.appendChild(cloneBtn);
|
|
btnRow.appendChild(openBtn);
|
|
leftGroup.appendChild(btnRow);
|
|
// Delete furthest LEFT, then Archive; Open/Clone group on the RIGHT.
|
|
// Nudge the Delete/Archive pair 8px left for alignment.
|
|
deleteBtn.style.cssText += ';position:relative;left:-8px;';
|
|
archiveBtn.style.cssText += ';position:relative;left:-8px;';
|
|
expandedActions.appendChild(deleteBtn);
|
|
expandedActions.appendChild(archiveBtn);
|
|
expandedActions.appendChild(leftGroup);
|
|
|
|
preview.appendChild(expandedActions);
|
|
card.appendChild(preview);
|
|
|
|
card.addEventListener('click', () => {
|
|
if (card._suppressNextClick) { card._suppressNextClick = false; return; }
|
|
if (_librarySelectMode) {
|
|
const cb = card.querySelector('.memory-select-cb');
|
|
if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
|
|
} else {
|
|
libraryExpandCard(card, doc);
|
|
}
|
|
});
|
|
_attachLongPressMenu(card, '.memory-item-btn');
|
|
return card;
|
|
}
|
|
|
|
async function libraryExpandCard(card, doc) {
|
|
const grid = card.closest('.doclib-grid');
|
|
const instant = card?.dataset?.spaceToggle === '1';
|
|
|
|
// Already expanded — collapse
|
|
if (card.classList.contains('doclib-card-expanded')) {
|
|
_collapseExpandedCard(card);
|
|
return;
|
|
}
|
|
|
|
// Collapse any other expanded card
|
|
if (grid) {
|
|
grid.querySelectorAll('.doclib-card-expanded').forEach(c => _collapseExpandedCard(c));
|
|
}
|
|
|
|
// Fade siblings out before the CSS display:none kicks in
|
|
const siblings = grid ? [...grid.querySelectorAll('.doclib-card')].filter(c => c !== card) : [];
|
|
// Force explicit starting opacity so the first transition works
|
|
siblings.forEach(s => { s.style.opacity = '1'; });
|
|
// Force reflow so the browser registers the starting value
|
|
if (!instant) {
|
|
if (siblings.length) siblings[0].offsetHeight;
|
|
siblings.forEach(s => { s.style.transition = 'opacity 0.12s ease'; s.style.opacity = '0'; });
|
|
}
|
|
|
|
// Capture the full grid + toolbar height so the modal stays the same
|
|
// size on desktop. On mobile the modal is full-height and we want the
|
|
// grid to claim all available space — skip the lock there.
|
|
const isMobile = window.innerWidth <= 768;
|
|
const toolbar = grid ? grid.closest('.admin-card')?.querySelector('.memory-toolbar') : null;
|
|
const toolbarH = toolbar ? toolbar.offsetHeight : 0;
|
|
if (grid && !isMobile) {
|
|
grid.style.minHeight = (grid.offsetHeight + toolbarH) + 'px';
|
|
grid.style.maxHeight = (grid.offsetHeight + toolbarH) + 'px';
|
|
}
|
|
|
|
// Wait for fade-out, then expand
|
|
if (!instant) await new Promise(r => setTimeout(r, 120));
|
|
|
|
card.classList.add('doclib-card-expanded');
|
|
if (grid) grid.scrollTop = 0;
|
|
|
|
// Clean up sibling inline styles (CSS display:none takes over now)
|
|
siblings.forEach(s => { s.style.transition = ''; s.style.opacity = ''; });
|
|
|
|
// Load full content into preview area
|
|
const preview = card.querySelector('.doclib-card-preview');
|
|
if (!preview) return;
|
|
|
|
const actionsBar = preview.querySelector('.doclib-card-expanded-actions');
|
|
const existingPre = preview.querySelector('pre');
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
|
|
if (!res.ok) throw new Error('Failed');
|
|
const full = await res.json();
|
|
const content = full.current_content || '';
|
|
const lang = full.language || doc.language || 'text';
|
|
|
|
// PDF-backed docs have a marker comment in their markdown — show the
|
|
// rendered PDF in an iframe instead of dumping markdown source.
|
|
const isPdfDoc = /<!--\s*pdf_(?:form_)?source\s+upload_id="[^"]+"/.test(content);
|
|
const existingFrame = preview.querySelector('.doclib-card-pdf-frame');
|
|
|
|
if (isPdfDoc) {
|
|
const frame = document.createElement('iframe');
|
|
frame.className = 'doclib-card-pdf-frame';
|
|
frame.src = `${API_BASE}/api/document/${doc.id}/render-pdf?t=${Date.now()}`;
|
|
frame.style.cssText = 'width:100%;height:60vh;border:1px solid var(--border);border-radius:6px;background:var(--bg);opacity:0;transition:opacity 0.15s ease;';
|
|
if (existingPre) existingPre.remove();
|
|
if (existingFrame) existingFrame.remove();
|
|
preview.insertBefore(frame, preview.firstChild);
|
|
if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar);
|
|
requestAnimationFrame(() => { frame.style.opacity = '1'; });
|
|
return;
|
|
}
|
|
|
|
const pre = document.createElement('pre');
|
|
const code = document.createElement('code');
|
|
// Syntax highlighting is synchronous and O(n) — running it over a whole
|
|
// large document froze the main thread on click (the "lag"). Only
|
|
// highlight up to a cap; bigger docs render as plain text (still fully
|
|
// shown) so the preview opens instantly. Markdown gains little from
|
|
// highlighting anyway, so skip it there.
|
|
const HL_CAP = 20000;
|
|
try {
|
|
if (lang && lang !== 'text' && lang !== 'markdown' && window.hljs && content.length <= HL_CAP) {
|
|
code.innerHTML = window.hljs.highlight(content, { language: lang }).value;
|
|
} else {
|
|
code.textContent = content;
|
|
}
|
|
} catch {
|
|
code.textContent = content;
|
|
}
|
|
pre.appendChild(code);
|
|
|
|
// Swap content — fade in the full version
|
|
if (existingPre) existingPre.remove();
|
|
if (existingFrame) existingFrame.remove();
|
|
pre.style.opacity = '0';
|
|
preview.insertBefore(pre, preview.firstChild);
|
|
if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar);
|
|
requestAnimationFrame(() => {
|
|
pre.style.transition = 'opacity 0.15s ease';
|
|
pre.style.opacity = '1';
|
|
});
|
|
} catch (e) {
|
|
// On error, keep existing preview if available
|
|
if (!existingPre) {
|
|
preview.innerHTML = '<div style="padding:8px;color:var(--color-error);font-size:10px;">Failed to load</div>';
|
|
}
|
|
if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar);
|
|
}
|
|
}
|
|
|
|
function libraryRenderLoadMore() {
|
|
// Documents now reveal in 20-at-a-time chunks via the inline "Load more"
|
|
// rendered inside libraryRenderGrid (matching the Chats tab). The legacy
|
|
// server-pagination button + auto-fill are retired to avoid a double
|
|
// control and surprise auto-loading.
|
|
const legacy = document.getElementById('doclib-load-more');
|
|
if (legacy) legacy.style.display = 'none';
|
|
}
|
|
|
|
async function libraryOpenDocument(doc) {
|
|
closeLibrary();
|
|
// Orphaned doc (session deleted) — just open in editor without switching session
|
|
if (!doc.session_id) {
|
|
_loadDocument(doc.id);
|
|
return;
|
|
}
|
|
const currentSessionId = sessionModule && sessionModule.getCurrentSessionId();
|
|
if (doc.session_id !== currentSessionId) {
|
|
await sessionModule.selectSession(doc.session_id);
|
|
}
|
|
_loadDocument(doc.id);
|
|
}
|
|
|
|
/** Open a document in its linked session */
|
|
async function libraryOpenInSession(doc) {
|
|
if (!doc.session_id) return;
|
|
closeLibrary();
|
|
|
|
// Step 1: switch session if needed and wait for it to load
|
|
const currentSessionId = sessionModule && sessionModule.getCurrentSessionId();
|
|
if (doc.session_id !== currentSessionId) {
|
|
await sessionModule.selectSession(doc.session_id);
|
|
// Give the session UI a moment to settle
|
|
await new Promise(r => setTimeout(r, 150));
|
|
}
|
|
|
|
// Step 2: ensure doc is in tabs
|
|
const docs = _getDocs();
|
|
if (!docs.has(doc.id)) {
|
|
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
|
|
if (res.ok) {
|
|
const full = await res.json();
|
|
_addDocToTabs(full, doc.session_id);
|
|
}
|
|
}
|
|
|
|
// Step 3: open panel (slide-in is handled by openPanel)
|
|
if (!_isOpenFn()) _openPanel();
|
|
|
|
_switchToDoc(doc.id);
|
|
_syncDocIndicator();
|
|
}
|
|
|
|
/** Copy a document from the library into the current session */
|
|
async function libraryImportDocument(doc) {
|
|
let sessionId = sessionModule && sessionModule.getCurrentSessionId();
|
|
if (!sessionId) {
|
|
// Create a new session if none exists
|
|
if (sessionModule && sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
|
|
const ok = await sessionModule.materializePendingSession();
|
|
if (ok) sessionId = sessionModule.getCurrentSessionId();
|
|
}
|
|
if (!sessionId) {
|
|
// No pending chat either — trigger new session, preserving the current model
|
|
const curModel = sessionModule.getCurrentModel ? sessionModule.getCurrentModel() : null;
|
|
const sessions = sessionModule ? sessionModule.getSessions() : [];
|
|
// Prefer the session matching the current model, otherwise fall back to first with a model
|
|
const withModel = sessions.filter(s => s.endpoint_url && s.model);
|
|
const match = (curModel && withModel.find(s => s.model === curModel)) || withModel[0];
|
|
if (match) {
|
|
sessionModule.createDirectChat(match.endpoint_url, match.model, match.endpoint_id);
|
|
const ok = await sessionModule.materializePendingSession();
|
|
if (ok) sessionId = sessionModule.getCurrentSessionId();
|
|
}
|
|
}
|
|
if (!sessionId) {
|
|
if (uiModule) uiModule.showError('Could not create a session');
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
// Fetch full content of the source document
|
|
const srcRes = await fetch(`${API_BASE}/api/document/${doc.id}`);
|
|
if (!srcRes.ok) throw new Error('Failed to fetch document');
|
|
const src = await srcRes.json();
|
|
|
|
// Deduplicate title — append (2), (3), etc. if name already exists in session
|
|
let baseTitle = src.title || doc.title || 'Untitled';
|
|
const existingTitles = new Set();
|
|
const docs = _getDocs();
|
|
for (const [, d] of docs) {
|
|
if (d.sessionId === sessionId && d.title) existingTitles.add(d.title);
|
|
}
|
|
if (existingTitles.has(baseTitle)) {
|
|
// Strip existing (N) suffix to get the root name
|
|
const root = baseTitle.replace(/\s*\(\d+\)$/, '');
|
|
let n = 2;
|
|
while (existingTitles.has(root + ' (' + n + ')')) n++;
|
|
baseTitle = root + ' (' + n + ')';
|
|
}
|
|
|
|
// Create a new document copy in the current session
|
|
const res = await fetch(`${API_BASE}/api/document`, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
// Preserve the source's type; default to markdown when unknown
|
|
// (the backend also sniffs, but this keeps the tab label correct).
|
|
language: src.language || doc.language || 'markdown',
|
|
content: src.current_content || '',
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to create document');
|
|
const created = await res.json();
|
|
closeLibrary();
|
|
_addDocToTabs(created, sessionId);
|
|
if (!_isOpenFn()) _openPanel();
|
|
|
|
_switchToDoc(created.id);
|
|
_syncDocIndicator();
|
|
if (uiModule) uiModule.showToast('Document cloned to session');
|
|
} catch (e) {
|
|
console.error('Failed to import document:', e);
|
|
if (uiModule) uiModule.showError('Failed to import document');
|
|
}
|
|
}
|
|
|
|
// ---- Library bulk operations ----
|
|
|
|
function libraryEnterSelectMode() {
|
|
_librarySelectMode = true;
|
|
_librarySelectedIds.clear();
|
|
const bulkBar = document.getElementById('doclib-bulk-bar');
|
|
const selectBtn = document.getElementById('doclib-select-btn');
|
|
if (bulkBar) bulkBar.classList.remove('hidden');
|
|
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.textContent = 'Cancel'; }
|
|
libraryUpdateBulkCount();
|
|
libraryRenderGrid();
|
|
}
|
|
|
|
function libraryExitSelectMode() {
|
|
_librarySelectMode = false;
|
|
_librarySelectedIds.clear();
|
|
const bulkBar = document.getElementById('doclib-bulk-bar');
|
|
const selectBtn = document.getElementById('doclib-select-btn');
|
|
const selectAll = document.getElementById('doclib-select-all');
|
|
if (bulkBar) bulkBar.classList.add('hidden');
|
|
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.textContent = 'Select'; }
|
|
if (selectAll) selectAll.checked = false;
|
|
libraryRenderGrid();
|
|
}
|
|
|
|
function libraryToggleSelectItem(id) {
|
|
if (_librarySelectedIds.has(id)) {
|
|
_librarySelectedIds.delete(id);
|
|
} else {
|
|
_librarySelectedIds.add(id);
|
|
}
|
|
libraryUpdateBulkCount();
|
|
}
|
|
|
|
function libraryToggleSelectAll() {
|
|
const selectAllEl = document.getElementById('doclib-select-all');
|
|
if (!selectAllEl) return;
|
|
if (selectAllEl.checked) {
|
|
_libraryDocs.forEach(d => _librarySelectedIds.add(d.id));
|
|
} else {
|
|
_librarySelectedIds.clear();
|
|
}
|
|
libraryUpdateBulkCount();
|
|
libraryRenderGrid();
|
|
}
|
|
|
|
function libraryUpdateBulkCount() {
|
|
const countEl = document.getElementById('doclib-selected-count');
|
|
const actionsBtn = document.getElementById('doclib-bulk-actions');
|
|
if (countEl) countEl.textContent = `${_librarySelectedIds.size} Selected`;
|
|
if (actionsBtn) actionsBtn.style.color = _librarySelectedIds.size > 0 ? 'var(--fg)' : '';
|
|
// Legacy per-action buttons no longer rendered — guard so the rest of the
|
|
// function (if anything still references them) doesn't crash.
|
|
const deleteBtn = document.getElementById('doclib-bulk-delete');
|
|
const exportBtn = document.getElementById('doclib-bulk-export');
|
|
const archiveBtn = document.getElementById('doclib-bulk-archive');
|
|
const cloneBtn = document.getElementById('doclib-bulk-clone');
|
|
if (deleteBtn) deleteBtn.disabled = _librarySelectedIds.size === 0;
|
|
if (exportBtn) exportBtn.disabled = _librarySelectedIds.size === 0;
|
|
if (cloneBtn) cloneBtn.disabled = _librarySelectedIds.size === 0;
|
|
if (archiveBtn) {
|
|
archiveBtn.disabled = _librarySelectedIds.size === 0;
|
|
archiveBtn.textContent = _libraryArchivedView ? 'Restore' : 'Archive';
|
|
}
|
|
}
|
|
|
|
async function libraryDeleteSingle(docId, card) {
|
|
if (uiModule && uiModule.styledConfirm) {
|
|
const ok = await uiModule.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true });
|
|
if (!ok) return;
|
|
} else if (!confirm('Delete this document?')) {
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${docId}`, { method: 'DELETE', credentials: 'same-origin' });
|
|
if (!res.ok) {
|
|
let detail = `HTTP ${res.status}`;
|
|
try { const j = await res.json(); if (j?.detail) detail = j.detail; } catch {}
|
|
throw new Error(detail);
|
|
}
|
|
if (card) {
|
|
card.classList.add('doclib-card-deleting');
|
|
card.addEventListener('transitionend', () => card.remove(), { once: true });
|
|
setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
|
|
}
|
|
_libraryDocs = _libraryDocs.filter(d => d.id !== docId);
|
|
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
|
libraryRenderStats();
|
|
if (uiModule) uiModule.showToast('Document deleted');
|
|
} catch (e) {
|
|
if (uiModule) uiModule.showError(`Failed to delete document: ${e.message || e}`);
|
|
}
|
|
}
|
|
|
|
async function libraryBulkDelete() {
|
|
if (_librarySelectedIds.size === 0) return;
|
|
const count = _librarySelectedIds.size;
|
|
if (uiModule && uiModule.styledConfirm) {
|
|
const ok = await uiModule.styledConfirm(
|
|
`Delete ${count} document${count !== 1 ? 's' : ''}?`,
|
|
{ confirmText: 'Delete', danger: true }
|
|
);
|
|
if (!ok) return;
|
|
} else if (!confirm(`Delete ${count} document${count !== 1 ? 's' : ''}?`)) {
|
|
return;
|
|
}
|
|
|
|
let deleted = 0;
|
|
let failed = 0;
|
|
const deletedIds = [];
|
|
for (const id of _librarySelectedIds) {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
|
if (res.ok) {
|
|
deleted++;
|
|
deletedIds.push(id);
|
|
}
|
|
else { failed++; console.warn('Delete failed for', id, 'status', res.status); }
|
|
} catch (e) {
|
|
failed++;
|
|
console.error('Failed to delete document:', id, e);
|
|
}
|
|
}
|
|
|
|
for (const id of deletedIds) {
|
|
const card = document.querySelector(`.doclib-card[data-doc-id="${CSS.escape(String(id))}"]`);
|
|
if (card) card.classList.add('doclib-card-deleting');
|
|
}
|
|
if (deletedIds.length) await new Promise(r => setTimeout(r, 320));
|
|
libraryExitSelectMode();
|
|
await libraryFetch(false);
|
|
if (uiModule) {
|
|
const msg = failed > 0
|
|
? `Deleted ${deleted} · ${failed} failed`
|
|
: `Deleted ${deleted} document${deleted !== 1 ? 's' : ''}`;
|
|
(failed > 0 ? uiModule.showError : uiModule.showToast)(msg);
|
|
}
|
|
}
|
|
|
|
async function libraryBulkArchive() {
|
|
if (_librarySelectedIds.size === 0) return;
|
|
const toArchived = !_libraryArchivedView;
|
|
const ids = [..._librarySelectedIds];
|
|
let done = 0, failed = 0;
|
|
for (const id of ids) {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
|
if (res.ok) done++; else failed++;
|
|
} catch { failed++; }
|
|
}
|
|
libraryExitSelectMode();
|
|
await libraryFetch(false);
|
|
if (uiModule) {
|
|
const verb = toArchived ? 'Archived' : 'Restored';
|
|
const msg = failed > 0 ? `${verb} ${done} · ${failed} failed` : `${verb} ${done} document${done !== 1 ? 's' : ''}`;
|
|
(failed > 0 ? uiModule.showError : uiModule.showToast)(msg);
|
|
}
|
|
}
|
|
|
|
// Bulk "Clone" — reuse libraryImportDocument for each selected doc.
|
|
// It handles session resolution + a possible new-session creation once
|
|
// (subsequent calls in the loop see the now-resolved session).
|
|
async function libraryBulkClone() {
|
|
if (_librarySelectedIds.size === 0) return;
|
|
const ids = [..._librarySelectedIds];
|
|
let done = 0, failed = 0;
|
|
for (const id of ids) {
|
|
const doc = _libraryDocs.find(d => d.id === id);
|
|
if (!doc) { failed++; continue; }
|
|
try {
|
|
const ok = await libraryImportDocument(doc);
|
|
if (ok === false) failed++; else done++;
|
|
} catch { failed++; }
|
|
}
|
|
libraryExitSelectMode();
|
|
if (uiModule) {
|
|
const msg = failed > 0
|
|
? `Cloned ${done} · ${failed} failed`
|
|
: `Cloned ${done} document${done !== 1 ? 's' : ''}`;
|
|
(failed > 0 ? uiModule.showError : uiModule.showToast)(msg);
|
|
}
|
|
}
|
|
|
|
async function libraryBulkExport() {
|
|
if (_librarySelectedIds.size === 0) return;
|
|
// More than 5 → one server-built .zip (mirrors the gallery's bulk export;
|
|
// browsers also block a flood of individual downloads).
|
|
if (_librarySelectedIds.size > 5) {
|
|
const ids = [..._librarySelectedIds];
|
|
try {
|
|
if (uiModule) uiModule.showToast(`Zipping ${ids.length} documents…`);
|
|
const res = await fetch(`${API_BASE}/api/documents/export-zip`, {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
if (!res.ok) throw new Error('zip failed');
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'documents.zip';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
|
if (uiModule) uiModule.showToast(`Exported ${ids.length} documents (zip)`);
|
|
} catch (e) {
|
|
if (uiModule) uiModule.showError('Failed to create zip');
|
|
}
|
|
return;
|
|
}
|
|
const extMap = {
|
|
javascript: '.js', python: '.py', html: '.html', css: '.css',
|
|
markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh',
|
|
sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp',
|
|
typescript: '.ts', ruby: '.rb', php: '.php', text: '.txt',
|
|
xml: '.xml', toml: '.toml', ini: '.ini',
|
|
};
|
|
|
|
const docs = await Promise.all([..._librarySelectedIds].map(async id => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${id}`);
|
|
if (!res.ok) return null;
|
|
return await res.json();
|
|
} catch (e) {
|
|
console.error('Failed to export document:', id, e);
|
|
return null;
|
|
}
|
|
}));
|
|
for (const doc of docs) {
|
|
if (!doc) continue;
|
|
const ext = extMap[doc.language] || '.txt';
|
|
const filename = (doc.title || 'document') + (doc.title && doc.title.includes('.') ? '' : ext);
|
|
const blob = new Blob([doc.current_content || ''], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
if (uiModule) uiModule.showToast(`Exported ${_librarySelectedIds.size} document${_librarySelectedIds.size !== 1 ? 's' : ''}`);
|
|
}
|
|
|
|
/** Lazy-load SheetJS for spreadsheet parsing */
|
|
let _xlsxReady = null;
|
|
function ensureXLSX() {
|
|
if (_xlsxReady) return _xlsxReady;
|
|
if (window.XLSX) return (_xlsxReady = Promise.resolve());
|
|
_xlsxReady = new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = '/static/lib/xlsx.full.min.js';
|
|
s.onload = resolve;
|
|
s.onerror = () => reject(new Error('Failed to load XLSX library'));
|
|
document.head.appendChild(s);
|
|
});
|
|
return _xlsxReady;
|
|
}
|
|
|
|
let _mammothReady = null;
|
|
function ensureMammoth() {
|
|
if (_mammothReady) return _mammothReady;
|
|
if (window.mammoth) return (_mammothReady = Promise.resolve());
|
|
_mammothReady = new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = '/static/lib/mammoth.browser.min.js';
|
|
s.onload = resolve;
|
|
s.onerror = () => reject(new Error('Failed to load DOCX library'));
|
|
document.head.appendChild(s);
|
|
});
|
|
return _mammothReady;
|
|
}
|
|
|
|
/** Convert HTML from mammoth to clean markdown */
|
|
function htmlToMarkdown(html) {
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
let md = '';
|
|
function walk(node) {
|
|
if (node.nodeType === 3) { md += node.textContent; return; }
|
|
if (node.nodeType !== 1) return;
|
|
const tag = node.tagName.toLowerCase();
|
|
if (tag === 'h1') { md += '\n# '; walkChildren(node); md += '\n'; }
|
|
else if (tag === 'h2') { md += '\n## '; walkChildren(node); md += '\n'; }
|
|
else if (tag === 'h3') { md += '\n### '; walkChildren(node); md += '\n'; }
|
|
else if (tag === 'h4') { md += '\n#### '; walkChildren(node); md += '\n'; }
|
|
else if (tag === 'strong' || tag === 'b') { md += '**'; walkChildren(node); md += '**'; }
|
|
else if (tag === 'em' || tag === 'i') { md += '*'; walkChildren(node); md += '*'; }
|
|
else if (tag === 'a') { md += '['; walkChildren(node); md += `](${node.href || ''})`; }
|
|
else if (tag === 'br') { md += '\n'; }
|
|
else if (tag === 'p') { md += '\n'; walkChildren(node); md += '\n'; }
|
|
else if (tag === 'ul' || tag === 'ol') { md += '\n'; walkChildren(node); }
|
|
else if (tag === 'li') {
|
|
const parent = node.parentElement?.tagName?.toLowerCase();
|
|
if (parent === 'ol') {
|
|
const idx = Array.from(node.parentElement.children).indexOf(node) + 1;
|
|
md += `${idx}. `;
|
|
} else { md += '- '; }
|
|
walkChildren(node);
|
|
md += '\n';
|
|
}
|
|
else if (tag === 'table') { md += '\n'; convertTable(node); md += '\n'; }
|
|
else if (tag === 'img') {
|
|
// Skip embedded base64 images — they produce huge unreadable blobs
|
|
const src = node.src || '';
|
|
if (!src.startsWith('data:')) {
|
|
md += ``;
|
|
} else if (node.alt) {
|
|
md += `*[image: ${node.alt}]*`;
|
|
}
|
|
}
|
|
else { walkChildren(node); }
|
|
}
|
|
function walkChildren(node) { for (const child of node.childNodes) walk(child); }
|
|
function convertTable(table) {
|
|
const rows = table.querySelectorAll('tr');
|
|
rows.forEach((tr, i) => {
|
|
const cells = tr.querySelectorAll('th, td');
|
|
md += '| ' + Array.from(cells).map(c => c.textContent.trim()).join(' | ') + ' |\n';
|
|
if (i === 0) md += '| ' + Array.from(cells).map(() => '---').join(' | ') + ' |\n';
|
|
});
|
|
}
|
|
walkChildren(doc.body);
|
|
return md.replace(/\n{3,}/g, '\n\n').trim();
|
|
}
|
|
|
|
/** Read file contents — handles text, spreadsheet, and DOCX formats */
|
|
async function readFileContent(file) {
|
|
const name = file.name.toLowerCase();
|
|
const isSpreadsheet = name.endsWith('.xlsx') || name.endsWith('.xls') || name.endsWith('.ods');
|
|
const isDocx = name.endsWith('.docx');
|
|
|
|
if (isSpreadsheet) {
|
|
await ensureXLSX();
|
|
const buf = await file.arrayBuffer();
|
|
const wb = window.XLSX.read(buf, { type: 'array' });
|
|
// Convert each sheet to CSV, join with a header per sheet
|
|
const parts = [];
|
|
for (const sheetName of wb.SheetNames) {
|
|
if (wb.SheetNames.length > 1) parts.push(`# Sheet: ${sheetName}`);
|
|
parts.push(window.XLSX.utils.sheet_to_csv(wb.Sheets[sheetName]));
|
|
}
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
if (isDocx) {
|
|
await ensureMammoth();
|
|
const buf = await file.arrayBuffer();
|
|
const result = await window.mammoth.convertToHtml({ arrayBuffer: buf });
|
|
return htmlToMarkdown(result.value);
|
|
}
|
|
|
|
// Plain text
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result);
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsText(file);
|
|
});
|
|
}
|
|
|
|
/** Import files from disk into the document library */
|
|
async function libraryImportFiles(fileList) {
|
|
const EXT_TO_LANG = {
|
|
'.py': 'python', '.js': 'javascript', '.ts': 'typescript',
|
|
'.html': 'html', '.htm': 'html', '.css': 'css', '.md': 'markdown',
|
|
'.json': 'json', '.yml': 'yaml', '.yaml': 'yaml', '.sh': 'bash',
|
|
'.bash': 'bash', '.sql': 'sql', '.rs': 'rust', '.go': 'go',
|
|
'.java': 'java', '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp',
|
|
'.rb': 'ruby', '.php': 'php', '.xml': 'xml',
|
|
'.toml': 'toml', '.ini': 'ini', '.txt': '', '.log': '',
|
|
'.cfg': 'ini', '.conf': 'ini', '.env': '', '.jsx': 'javascript',
|
|
'.tsx': 'typescript', '.vue': 'html', '.svelte': 'html',
|
|
'.scss': 'css', '.sass': 'css', '.less': 'css',
|
|
'.csv': 'csv', '.tsv': 'csv',
|
|
'.xlsx': 'csv', '.xls': 'csv', '.ods': 'csv',
|
|
'.docx': 'markdown', '.doc': 'markdown',
|
|
};
|
|
|
|
let imported = 0;
|
|
let failed = 0;
|
|
let _firstErr = '';
|
|
|
|
// Library imports aren't tied to a chat — the backend now accepts a
|
|
// session-less "library" document, so no session_id is sent.
|
|
for (const file of fileList) {
|
|
try {
|
|
const name = file.name;
|
|
const dotIdx = name.lastIndexOf('.');
|
|
const ext = dotIdx >= 0 ? name.slice(dotIdx).toLowerCase() : '';
|
|
const baseTitle = dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
|
const language = EXT_TO_LANG[ext] !== undefined ? EXT_TO_LANG[ext] : null;
|
|
|
|
const isSpreadsheet = ['.xlsx', '.xls', '.ods'].includes(ext);
|
|
const isPdf = ext === '.pdf';
|
|
|
|
if (isPdf) {
|
|
// Backend handles save + AcroForm detection in one shot — picks the
|
|
// right doc kind so fillable forms get clickable inputs in the PDF
|
|
// view, and plain PDFs get the static page-image viewer.
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const res = await fetch(`${API_BASE}/api/documents/import-pdf`, {
|
|
method: 'POST',
|
|
body: fd,
|
|
});
|
|
if (!res.ok) {
|
|
let _e = `HTTP ${res.status}`;
|
|
try { const _j = await res.json(); _e = _j.detail || _j.error || _e; } catch {}
|
|
throw new Error('PDF import failed: ' + _e);
|
|
}
|
|
imported++;
|
|
continue;
|
|
}
|
|
|
|
if (isSpreadsheet) {
|
|
// Multi-sheet: create one document per sheet
|
|
await ensureXLSX();
|
|
const buf = await file.arrayBuffer();
|
|
const wb = window.XLSX.read(buf, { type: 'array' });
|
|
for (const sheetName of wb.SheetNames) {
|
|
const csv = window.XLSX.utils.sheet_to_csv(wb.Sheets[sheetName]);
|
|
if (!csv.trim()) continue;
|
|
const sheetTitle = wb.SheetNames.length > 1
|
|
? `${baseTitle} - ${sheetName}` : baseTitle;
|
|
const res = await fetch(`${API_BASE}/api/document`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: sheetTitle, language: 'csv', content: csv }),
|
|
});
|
|
if (!res.ok) throw new Error('Server error');
|
|
}
|
|
imported++;
|
|
} else {
|
|
const content = await readFileContent(file);
|
|
const res = await fetch(`${API_BASE}/api/document`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: baseTitle, language, content }),
|
|
});
|
|
if (!res.ok) throw new Error('Server error');
|
|
imported++;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to import file:', file.name, e);
|
|
if (!_firstErr) _firstErr = (e && e.message) || String(e);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
const msg = `Imported ${imported} file${imported !== 1 ? 's' : ''}` +
|
|
(failed ? `, ${failed} failed${_firstErr ? ' — ' + _firstErr : ''}` : '');
|
|
if (failed && uiModule) uiModule.showError(msg);
|
|
else if (uiModule) uiModule.showToast(msg);
|
|
await libraryFetch(false);
|
|
}
|
|
|
|
export function openLibrary(opts) {
|
|
if (_libraryOpen) {
|
|
// Recover from stuck state: the swipe-to-dismiss in ui.js adds .hidden
|
|
// to the modal without calling closeLibrary, so _libraryOpen can stay
|
|
// true even though the modal is gone or invisible. Detect and reset.
|
|
const existing = document.getElementById('doclib-modal');
|
|
if (!existing || existing.classList.contains('hidden')) {
|
|
if (existing) existing.remove();
|
|
_libraryOpen = false;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
_libraryOpen = true;
|
|
_libraryImportMode = !!(opts && opts.import);
|
|
_librarySelectMode = false;
|
|
_librarySelectedIds.clear();
|
|
_librarySearch = '';
|
|
_libraryActiveLanguage = null;
|
|
_librarySort = 'recent';
|
|
_libraryOffset = 0;
|
|
_libraryDocs = [];
|
|
|
|
// Create modal
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal';
|
|
modal.id = 'doclib-modal';
|
|
modal.innerHTML = `
|
|
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
|
|
<div class="modal-header">
|
|
<h4><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Library</h4>
|
|
<button class="close-btn" id="doclib-close">\u2716</button>
|
|
</div>
|
|
<div class="lib-tabs" id="doclib-lib-tabs" style="padding:0 10px;">
|
|
<button class="lib-tab" data-doclib-tab="chats"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chats</button>
|
|
<button class="lib-tab active" data-doclib-tab="documents"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>Documents</button>
|
|
<button class="lib-tab" data-doclib-tab="research"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Research</button>
|
|
<button class="lib-tab" data-doclib-tab="archive"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button>
|
|
</div>
|
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;">
|
|
<div id="doclib-panel-chats" data-doclib-panel="chats" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;">
|
|
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
|
|
<h2 style="margin:0;padding:0;line-height:1;">Chats <span id="doclib-chats-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
|
</div>
|
|
<p class="memory-desc doclib-desc">All active chat sessions. Click to open.</p>
|
|
<div class="memory-toolbar">
|
|
<div class="memory-category-filters">
|
|
<select class="memory-sort-select" id="doclib-chats-sort">
|
|
<option value="recent">Recent</option>
|
|
<option value="oldest">Oldest</option>
|
|
<option value="most-messages">Most messages</option>
|
|
<option value="alpha">A\u2013Z</option>
|
|
</select>
|
|
<button class="memory-toolbar-btn" id="doclib-chats-select-btn">Select</button>
|
|
<button class="memory-toolbar-btn" id="doclib-chats-tidy-btn" title="AI tidy: delete junk sessions and organize into folders"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
|
|
</div>
|
|
<input type="text" id="doclib-chats-search" placeholder="Search chats\u2026" class="memory-search-input" />
|
|
<div id="doclib-chats-chips" class="doclib-lang-chips"></div>
|
|
</div>
|
|
<div id="doclib-chats-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
|
|
<label class="memory-bulk-check-all" style="position:relative;top:0px;left:-1px;"><input type="checkbox" id="doclib-chats-select-all" style="position:relative;top:0px;"> All</label>
|
|
<span id="doclib-chats-selected-count">0 Selected</span>
|
|
<button class="memory-toolbar-btn" id="doclib-chats-bulk-archive" style="position:relative;top:-3px;left:2px;"><svg 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:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button>
|
|
<button class="memory-toolbar-btn danger" id="doclib-chats-bulk-delete" style="position:relative;left:2px;"><svg 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:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
|
|
<button class="memory-toolbar-btn" id="doclib-chats-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;left:2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
|
</div>
|
|
<div id="doclib-chats-grid" class="doclib-grid"></div>
|
|
</div>
|
|
<div id="doclib-panel-archive" data-doclib-panel="archive" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;">
|
|
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
|
|
<h2 style="margin:0;padding:0;line-height:1;position:relative;top:2px;">Archive <span id="doclib-arc-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
|
</div>
|
|
<p class="memory-desc doclib-desc" style="position:relative;top:0.5px;">Archived sessions. Restore to make active again.</p>
|
|
<div class="memory-toolbar">
|
|
<div class="memory-category-filters">
|
|
<select class="memory-sort-select" id="doclib-arc-sort">
|
|
<option value="recent">Recent</option>
|
|
<option value="oldest">Oldest</option>
|
|
<option value="most-messages">Most messages</option>
|
|
<option value="alpha">A\u2013Z</option>
|
|
</select>
|
|
<button class="memory-toolbar-btn" id="doclib-arc-select-btn">Select</button>
|
|
</div>
|
|
<input type="text" id="doclib-arc-search" placeholder="Search archive\u2026" class="memory-search-input" />
|
|
<div id="doclib-arc-chips" class="doclib-lang-chips"></div>
|
|
</div>
|
|
<div id="doclib-arc-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
|
|
<label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-arc-select-all"> All</label>
|
|
<span id="doclib-arc-selected-count">0 Selected</span>
|
|
<button class="memory-toolbar-btn" id="doclib-arc-bulk-restore" style="position:relative;top:-3px;"><svg 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:-1px;margin-right:3px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>Restore</button>
|
|
<button class="memory-toolbar-btn danger" id="doclib-arc-bulk-delete"><svg 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:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
|
|
<button class="memory-toolbar-btn" id="doclib-arc-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
|
</div>
|
|
<div id="doclib-arc-grid" class="doclib-grid"></div>
|
|
</div>
|
|
<div id="doclib-panel-research" data-doclib-panel="research" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;">
|
|
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:10px;">
|
|
<h2 style="margin:0;padding:0;line-height:1;">Research <span id="doclib-research-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
|
</div>
|
|
<p class="memory-desc doclib-desc" style="position:relative;top:-1px;">Completed deep research reports. Click to view.</p>
|
|
<div class="memory-toolbar">
|
|
<div class="memory-category-filters">
|
|
<select class="memory-sort-select" id="doclib-research-sort">
|
|
<option value="recent">Recent</option>
|
|
<option value="oldest">Oldest</option>
|
|
<option value="most-sources">Most sources</option>
|
|
<option value="alpha">A\u2013Z</option>
|
|
</select>
|
|
<button class="memory-toolbar-btn" id="doclib-research-select-btn">Select</button>
|
|
<button class="memory-toolbar-btn" id="doclib-research-tidy-btn" title="Tidy: delete research with no sources or empty reports">Tidy</button>
|
|
</div>
|
|
<input type="text" id="doclib-research-search" placeholder="Search research\u2026" class="memory-search-input" />
|
|
</div>
|
|
<div id="doclib-research-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
|
|
<label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-research-select-all"> All</label>
|
|
<span id="doclib-research-selected-count">0 Selected</span>
|
|
<button class="memory-toolbar-btn" id="doclib-research-bulk-archive" style="position:relative;top:-2px;"><svg 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:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button>
|
|
<button class="memory-toolbar-btn danger" id="doclib-research-bulk-delete" style="position:relative;top:-2px;"><svg 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:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
|
|
<button class="memory-toolbar-btn" id="doclib-research-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
|
</div>
|
|
<div id="doclib-research-grid" class="doclib-grid"></div>
|
|
</div>
|
|
<div data-doclib-panel="documents" class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
|
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
|
|
<h2 style="margin:0;padding:0;line-height:1;">Documents <span id="doclib-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
|
<button class="memory-toolbar-btn" id="doclib-import-file-btn" title="Import files from disk" style="margin-left:auto;"><svg 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:-1px;margin-right:2px;"><polyline points="7 10 12 5 17 10"/><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="21" x2="19" y2="21"/></svg> Import</button>
|
|
<button class="memory-toolbar-btn" id="doclib-create-btn" title="Create new blank document"><svg 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:-1px;margin-right:3px;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> Create</button>
|
|
</div>
|
|
<p class="memory-desc doclib-desc">Open documents in a session, clone to a new or import new files.</p>
|
|
<div class="memory-toolbar">
|
|
<div class="memory-category-filters">
|
|
<select class="memory-sort-select" id="doclib-sort">
|
|
<option value="recent">Recent</option>
|
|
<option value="oldest">Oldest</option>
|
|
<option value="edits">Most edits</option>
|
|
<option value="alpha">A\u2013Z</option>
|
|
</select>
|
|
<button class="memory-toolbar-btn" id="doclib-select-btn" title="Select documents">Select</button>
|
|
<button class="memory-toolbar-btn" id="doclib-tidy-btn" title="Tidy: remove empty / junk / duplicate documents">Tidy</button>
|
|
</div>
|
|
<input type="text" id="doclib-search" placeholder="Search titles & content\u2026" class="memory-search-input" />
|
|
<div id="doclib-chips" class="doclib-lang-chips"></div>
|
|
</div>
|
|
<input type="file" id="doclib-file-input" multiple style="display:none" />
|
|
<div id="doclib-bulk-bar" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
|
|
<label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-select-all" /> All</label>
|
|
<span id="doclib-selected-count">0 Selected</span>
|
|
<button id="doclib-bulk-actions" class="memory-toolbar-btn" style="position:relative;top:-2px;margin-left:auto;"><svg 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:-1px;margin-right:3px;"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Actions <span style="opacity:0.55;font-size:9px;">▼</span></button>
|
|
<button id="doclib-bulk-cancel" class="memory-toolbar-btn" title="Cancel (Esc)" style="margin-left:4px;margin-right:4px;padding:3px 6px;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
|
</div>
|
|
<div class="doclib-grid" id="doclib-grid"></div>
|
|
<button class="doclib-load-more" id="doclib-load-more" style="display:none">Load more</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
// Make modal draggable (same logic as other modals)
|
|
{
|
|
const content = modal.querySelector('.modal-content');
|
|
const header = modal.querySelector('.modal-header');
|
|
if (content && header) {
|
|
// Restore saved position / fullscreen state
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem('doclib-pos'));
|
|
if (saved && saved.fullscreen) {
|
|
localStorage.removeItem('doclib-pos');
|
|
} else if (saved && saved.left && saved.top) {
|
|
content.style.position = 'fixed';
|
|
content.style.left = saved.left;
|
|
content.style.top = saved.top;
|
|
content.style.margin = '0';
|
|
// Clamp to viewport in case window was resized
|
|
requestAnimationFrame(() => {
|
|
const r = content.getBoundingClientRect();
|
|
if (r.right > window.innerWidth) content.style.left = Math.max(0, window.innerWidth - r.width - 8) + 'px';
|
|
if (r.bottom > window.innerHeight) content.style.top = Math.max(0, window.innerHeight - r.height - 8) + 'px';
|
|
if (r.left < 0) content.style.left = '8px';
|
|
if (r.top < 0) content.style.top = '8px';
|
|
});
|
|
}
|
|
} catch {}
|
|
// Replaced ~150 lines of inline drag/snap/dock with one helper call.
|
|
// Library intentionally disables top-edge fullscreen snap: that layout
|
|
// breaks dense icon/tool rows. Side docking still works.
|
|
const FS_CLASS = 'doclib-fullscreen';
|
|
const enterFullscreen = () => {
|
|
if (modal.classList.contains(FS_CLASS)) return;
|
|
modal.classList.add(FS_CLASS);
|
|
content.style.position = 'fixed';
|
|
content.style.left = '0';
|
|
content.style.top = '0';
|
|
content.style.right = '0';
|
|
content.style.bottom = '0';
|
|
content.style.width = '100vw';
|
|
content.style.maxWidth = '100vw';
|
|
content.style.height = '100vh';
|
|
content.style.maxHeight = '100vh';
|
|
content.style.borderRadius = '0';
|
|
content.style.margin = '0';
|
|
content.style.transform = 'none';
|
|
try { localStorage.setItem('doclib-pos', JSON.stringify({ fullscreen: true })); } catch {}
|
|
};
|
|
const exitFullscreen = (cx, cy) => {
|
|
if (!modal.classList.contains(FS_CLASS)) return;
|
|
modal.classList.remove(FS_CLASS);
|
|
content.style.width = '';
|
|
content.style.maxWidth = '';
|
|
content.style.height = '';
|
|
content.style.maxHeight = '';
|
|
content.style.borderRadius = '';
|
|
content.style.right = '';
|
|
content.style.bottom = '';
|
|
const r0 = content.getBoundingClientRect();
|
|
const w = r0.width || Math.min(900, window.innerWidth * 0.92);
|
|
content.style.left = Math.max(8, cx - w / 2) + 'px';
|
|
content.style.top = Math.max(8, cy - 20) + 'px';
|
|
};
|
|
makeWindowDraggable(modal, {
|
|
content,
|
|
header,
|
|
fsClass: FS_CLASS,
|
|
skipSelector: '.modal-close',
|
|
onEnterFullscreen: enterFullscreen,
|
|
onExitFullscreen: exitFullscreen,
|
|
enableFullscreen: false,
|
|
onDragEnd: () => {
|
|
try { localStorage.setItem('doclib-pos', JSON.stringify({ left: content.style.left, top: content.style.top })); } catch {}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Wire events
|
|
document.getElementById('doclib-close').addEventListener('click', closeLibrary);
|
|
|
|
// Tab switching — Chats / Documents / Archive / Research
|
|
let _activeLibTab = (opts && opts.tab) || 'documents';
|
|
const _tabBtns = modal.querySelectorAll('[data-doclib-tab]');
|
|
const _tabPanels = modal.querySelectorAll('[data-doclib-panel]');
|
|
|
|
// Client-side pagination for tabs whose API returns everything at once
|
|
// (chats/archive/research). Render only this many initially; the
|
|
// load-more button reveals more in chunks.
|
|
const _LIB_PAGE_SIZE = 20;
|
|
let _chatsVisibleLimit = _LIB_PAGE_SIZE;
|
|
let _arcVisibleLimit = _LIB_PAGE_SIZE;
|
|
let _researchVisibleLimit = _LIB_PAGE_SIZE;
|
|
|
|
function _appendInlineLoadMore(grid, totalCount, currentLimit, onClick) {
|
|
if (!grid || !grid.parentElement) return;
|
|
// Drop the previous instance (if any) — we re-render the list from
|
|
// scratch each pass, so the button is regenerated alongside it.
|
|
grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove());
|
|
if (totalCount <= currentLimit) return;
|
|
const btn = document.createElement('button');
|
|
btn.className = 'doclib-load-more doclib-inline-load-more';
|
|
btn.textContent = `Load more (${currentLimit} of ${totalCount})`;
|
|
btn.addEventListener('click', onClick);
|
|
grid.parentElement.appendChild(btn);
|
|
}
|
|
|
|
function _switchLibTab(tab) {
|
|
_activeLibTab = tab;
|
|
_tabBtns.forEach(b => b.classList.toggle('active', b.dataset.doclibTab === tab));
|
|
_tabPanels.forEach(p => {
|
|
if (p.dataset.doclibPanel === tab) {
|
|
p.style.display = 'flex';
|
|
} else {
|
|
p.style.display = 'none';
|
|
}
|
|
});
|
|
if (tab === 'chats') _renderLibChats();
|
|
else if (tab === 'archive') _renderLibArchive();
|
|
else if (tab === 'research') _renderLibResearch();
|
|
}
|
|
|
|
_tabBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => _switchLibTab(btn.dataset.doclibTab));
|
|
});
|
|
|
|
// ── Chats tab state ──
|
|
let _chatsSessions = [];
|
|
let _chatsSearch = '';
|
|
let _chatsSort = 'recent';
|
|
let _chatsSelectMode = false;
|
|
const _chatsSelected = new Set();
|
|
let _chatsModelFilter = '';
|
|
|
|
function _renderLibChats() {
|
|
const grid = document.getElementById('doclib-chats-grid');
|
|
if (!grid) return;
|
|
grid.innerHTML = '';
|
|
grid.appendChild(spinnerModule.createLoadingRow('Loading…'));
|
|
fetch(API_BASE + '/api/sessions', { credentials: 'same-origin' }).then(r => r.json()).then(data => {
|
|
const raw = Array.isArray(data) ? data : (data.sessions || []);
|
|
_chatsSessions = raw.filter(s => !s.archived);
|
|
_renderChatsGrid();
|
|
_renderChatsChips();
|
|
}).catch(() => { grid.innerHTML = '<div class="doclib-empty">Failed to load</div>'; });
|
|
}
|
|
|
|
// Tap a chat row to expand inline: fetches the recent messages and
|
|
// renders them as a preview with an "Open chat" button. Tap again to
|
|
// collapse. Mirrors the documents-tab expand pattern.
|
|
async function _toggleChatPreview(card, session) {
|
|
const preview = card.querySelector('.doclib-chat-preview');
|
|
if (!preview) return;
|
|
const isOpen = card.classList.contains('doclib-card-expanded');
|
|
// Collapse any other open preview in this grid first
|
|
const grid = card.closest('.doclib-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.doclib-card-expanded').forEach(c => {
|
|
if (c !== card) {
|
|
c.classList.remove('doclib-card-expanded');
|
|
const p = c.querySelector('.doclib-chat-preview');
|
|
if (p) { p.style.display = 'none'; p.innerHTML = ''; }
|
|
}
|
|
});
|
|
}
|
|
if (isOpen) {
|
|
card.classList.remove('doclib-card-expanded');
|
|
preview.style.display = 'none';
|
|
preview.innerHTML = '';
|
|
return;
|
|
}
|
|
card.classList.add('doclib-card-expanded');
|
|
preview.style.display = 'block';
|
|
preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>';
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/history/${session.id}`, { credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error('Failed');
|
|
const data = await res.json();
|
|
const history = Array.isArray(data) ? data : (data.history || []);
|
|
const recent = history.filter(m => m.role === 'user' || m.role === 'assistant').slice(-5);
|
|
const sessionModel = (session.model || '').split('/').pop();
|
|
const msgsHtml = recent.length
|
|
? recent.map(m => {
|
|
const isUser = m.role === 'user';
|
|
const raw = m.content || '';
|
|
const truncated = raw.length > 600 ? raw.slice(0, 600) + '…' : raw;
|
|
// Strip thinking blocks (internal model state) and render with
|
|
// the same markdown pipeline the chat uses.
|
|
const cleaned = truncated
|
|
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
|
.replace(/<think>[\s\S]*$/, '')
|
|
.trim();
|
|
let body;
|
|
try {
|
|
body = markdownModule.mdToHtml(cleaned);
|
|
} catch { body = _esc(cleaned); }
|
|
// Per-message model can override the session default (e.g.
|
|
// when comparing models in the same chat).
|
|
const msgModel = (m.metadata && (m.metadata.model || m.metadata.model_name)) || '';
|
|
const modelTag = !isUser && (msgModel || sessionModel)
|
|
? `<span class="doclib-chat-msg-model">${_esc(msgModel || sessionModel)}</span>`
|
|
: '';
|
|
return `<div class="doclib-chat-bubble-row ${isUser ? 'user' : 'assistant'}">
|
|
<div class="doclib-chat-bubble">
|
|
${modelTag}
|
|
<div class="doclib-chat-bubble-body">${body}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('')
|
|
: '<div style="opacity:0.4;font-size:11px;padding:6px 4px;">No messages yet</div>';
|
|
const isArchive = !!session.archived;
|
|
// Archived chats get a Restore button (unarchive); active chats get the
|
|
// Archive button. Matches the research + document archive previews.
|
|
const archiveHtml = isArchive
|
|
? '<button class="doclib-chat-restore-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>' +
|
|
'Restore' +
|
|
'</button>'
|
|
: '<button class="doclib-chat-archive-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' +
|
|
'Archive' +
|
|
'</button>';
|
|
// Copy sits next to Archive on the left side of the action row.
|
|
// Uses the same border-only secondary-action style — distinct from
|
|
// the danger Delete (red) and the primary Open (right-aligned).
|
|
// Copy is hidden in the Archive (keep the footer to Delete + Restore +
|
|
// Open there). It still shows for active chats.
|
|
const copyHtml = isArchive ? '' : '<button class="doclib-chat-copy-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' +
|
|
'Copy' +
|
|
'</button>';
|
|
const deleteHtml = '<button class="doclib-chat-delete-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>' +
|
|
'Delete' +
|
|
'</button>';
|
|
preview.innerHTML =
|
|
'<div class="doclib-chat-preview-messages">' + msgsHtml + '</div>' +
|
|
'<div class="doclib-chat-preview-actions">' +
|
|
deleteHtml +
|
|
archiveHtml +
|
|
copyHtml +
|
|
'<button class="doclib-chat-open-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>' +
|
|
'Open' +
|
|
'</button>' +
|
|
'</div>';
|
|
const openBtn = preview.querySelector('.doclib-chat-open-btn');
|
|
if (openBtn) openBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (window.sessionModule) window.sessionModule.selectSession(session.id);
|
|
closeLibrary();
|
|
// Also collapse the wide sidebar so the picked chat sits
|
|
// fullscreen — same gesture as picking a session in the
|
|
// sidebar itself on mobile. Skip on desktop where the user
|
|
// expects the sidebar to stay where they left it.
|
|
if (window.innerWidth <= 768) {
|
|
const sb = document.getElementById('sidebar');
|
|
if (sb) {
|
|
sb.classList.add('hidden');
|
|
try { window.syncRailSide && window.syncRailSide(); } catch (_) {}
|
|
}
|
|
}
|
|
});
|
|
const archiveBtn = preview.querySelector('.doclib-chat-archive-btn');
|
|
if (archiveBtn) archiveBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
await fetch(API_BASE + '/api/session/' + session.id + '/archive', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
_renderLibChats();
|
|
});
|
|
const restoreBtn = preview.querySelector('.doclib-chat-restore-btn');
|
|
if (restoreBtn) restoreBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
await fetch(API_BASE + '/api/session/' + session.id + '/unarchive', { method: 'POST' });
|
|
_renderLibArchive();
|
|
});
|
|
const copyBtn = preview.querySelector('.doclib-chat-copy-btn');
|
|
if (copyBtn) copyBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
_copyChatById(session.id);
|
|
});
|
|
const deleteBtn = preview.querySelector('.doclib-chat-delete-btn');
|
|
if (deleteBtn) deleteBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
if (!await window.styledConfirm('Delete this chat?', { confirmText: 'Delete', danger: true })) return;
|
|
await fetch(API_BASE + '/api/session/' + session.id, { method: 'DELETE' });
|
|
card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`;
|
|
card.classList.add('memory-tidy-removing');
|
|
await new Promise(r => setTimeout(r, 520));
|
|
if (isArchive) _renderLibArchive(); else _renderLibChats();
|
|
});
|
|
} catch (e) {
|
|
preview.innerHTML = '<div style="opacity:0.5;font-size:11px;padding:6px 4px;color:var(--color-error);">Failed to load preview</div>';
|
|
}
|
|
}
|
|
|
|
function _renderChatsGrid() {
|
|
const grid = document.getElementById('doclib-chats-grid');
|
|
if (!grid) return;
|
|
const _csb = document.getElementById('doclib-chats-select-btn');
|
|
if (_csb) { _csb.classList.toggle('active', _chatsSelectMode); _csb.textContent = _chatsSelectMode ? 'Cancel' : 'Select'; }
|
|
let filtered = _chatsSessions.slice();
|
|
if (_chatsSearch) {
|
|
const q = _chatsSearch.toLowerCase();
|
|
filtered = filtered.filter(s => (s.name || '').toLowerCase().includes(q) || (s.model || '').toLowerCase().includes(q));
|
|
}
|
|
if (_chatsModelFilter) filtered = filtered.filter(s => s.folder === _chatsModelFilter);
|
|
if (_chatsSort === 'oldest') filtered.sort((a, b) => (a.updated_at || '') > (b.updated_at || '') ? 1 : -1);
|
|
else if (_chatsSort === 'most-messages') filtered.sort((a, b) => (b.message_count || 0) - (a.message_count || 0));
|
|
else if (_chatsSort === 'alpha') filtered.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
else filtered.sort((a, b) => (b.updated_at || '') > (a.updated_at || '') ? 1 : -1);
|
|
|
|
const stats = document.getElementById('doclib-chats-stats');
|
|
if (stats) stats.textContent = filtered.length + ' chat' + (filtered.length !== 1 ? 's' : '');
|
|
|
|
if (!filtered.length) {
|
|
// Sad-mouth smiley (downturn curve) for "nothing here yet".
|
|
const _sadIco = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('sad') + '</span>';
|
|
grid.innerHTML = '<div class="doclib-empty">No chats' + _sadIco + '</div>';
|
|
_appendInlineLoadMore(grid, 0, _chatsVisibleLimit, () => {});
|
|
return;
|
|
}
|
|
const total = filtered.length;
|
|
const visible = filtered.slice(0, _chatsVisibleLimit);
|
|
grid.innerHTML = '';
|
|
_maybeCascadeGrid(grid, 'chats');
|
|
for (const s of visible) {
|
|
const card = document.createElement('div');
|
|
card.className = 'memory-item doclib-chat-row';
|
|
card.style.cursor = 'pointer';
|
|
card.dataset.sid = s.id;
|
|
const model = (s.model || '').split('/').pop();
|
|
const cbHtml = _chatsSelectMode ? '<input type="checkbox" class="memory-select-cb"' + (_chatsSelected.has(s.id) ? ' checked' : '') + '>' : '';
|
|
const chatIconSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
|
const chevronSvg = '<span class="doclib-card-chevron"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>';
|
|
// Msg count badge inside the title, dimmer than the name so it
|
|
// reads as metadata at a glance. Hidden when count is 0 so
|
|
// brand-new "New Chat" rows don't show "\u00b7 0 msgs".
|
|
const _chatMsgs = s.message_count || 0;
|
|
const msgCountHtml = _chatMsgs > 0
|
|
? '<span style="opacity:0.45;font-weight:normal;font-size:0.9em;margin-left:6px;">\u00b7 ' + _chatMsgs + ' msg' + (_chatMsgs === 1 ? '' : 's') + '</span>'
|
|
: '';
|
|
card.innerHTML =
|
|
'<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
|
|
cbHtml +
|
|
'<div style="flex:1;min-width:0;">' +
|
|
'<div class="memory-item-title">' + chatIconSvg + _esc(s.name || 'Untitled') + msgCountHtml + '</div>' +
|
|
'<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + [model, _relTime(s.updated_at)].filter(Boolean).join(' \u00b7 ') + '</div>' +
|
|
'</div>' +
|
|
chevronSvg +
|
|
'<div class="memory-item-actions"><button class="memory-item-btn _chat-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' +
|
|
'</div>' +
|
|
'<div class="doclib-chat-preview" style="display:none;"></div>';
|
|
const cb = card.querySelector('.memory-select-cb');
|
|
if (cb) { cb.addEventListener('click', e => e.stopPropagation()); cb.addEventListener('change', () => { if (cb.checked) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); _updateChatsCount(); }); }
|
|
card.querySelector('._chat-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [
|
|
{ label: 'Open', action: () => { if (window.sessionModule) window.sessionModule.selectSession(s.id); } },
|
|
{ label: 'Copy', action: () => _copyChatById(s.id) },
|
|
{ label: 'Archive', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/archive', { method: 'POST', headers: {'Content-Type':'application/json'} }); _renderLibChats(); } },
|
|
{ label: 'Delete', action: async () => {
|
|
await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' });
|
|
card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`;
|
|
card.classList.add('memory-tidy-removing');
|
|
await new Promise(r => setTimeout(r, 520));
|
|
_renderLibChats();
|
|
}, danger: true },
|
|
], { onSelect: () => {
|
|
_chatsSelectMode = true;
|
|
_chatsSelected.add(s.id);
|
|
document.getElementById('doclib-chats-bulk')?.classList.remove('hidden');
|
|
_renderChatsGrid();
|
|
} }); });
|
|
card.addEventListener('click', (e) => {
|
|
if (card._suppressNextClick) { card._suppressNextClick = false; return; }
|
|
if (_chatsSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); _updateChatsCount(); } return; }
|
|
if (e.target.closest('._chat-menu') || e.target.closest('.memory-select-cb') || e.target.closest('.doclib-chat-open-btn')) return;
|
|
_toggleChatPreview(card, s);
|
|
});
|
|
_attachLongPressMenu(card, '._chat-menu');
|
|
grid.appendChild(card);
|
|
}
|
|
_appendInlineLoadMore(grid, total, _chatsVisibleLimit, () => {
|
|
_chatsVisibleLimit += _LIB_PAGE_SIZE;
|
|
_renderChatsGrid();
|
|
});
|
|
}
|
|
|
|
function _renderChatsChips() {
|
|
const el = document.getElementById('doclib-chats-chips');
|
|
if (!el) return;
|
|
const counts = {};
|
|
_chatsSessions.forEach(s => { const f = s.folder; if (f) counts[f] = (counts[f] || 0) + 1; });
|
|
const folders = Object.keys(counts).sort();
|
|
if (folders.length < 1) { el.innerHTML = ''; return; }
|
|
el.innerHTML = '';
|
|
const mk = (label, val, count) => { const c = document.createElement('button'); c.className = 'memory-cat-chip' + (_chatsModelFilter === val ? ' active' : ''); c.textContent = label + ' (' + count + ')'; c.addEventListener('click', () => { _chatsModelFilter = _chatsModelFilter === val ? '' : val; _renderChatsGrid(); _renderChatsChips(); }); el.appendChild(c); };
|
|
mk('all', '', _chatsSessions.length);
|
|
folders.forEach(f => mk(f, f, counts[f]));
|
|
}
|
|
|
|
function _updateChatsCount() { const el = document.getElementById('doclib-chats-selected-count'); if (el) el.textContent = _chatsSelected.size + ' Selected'; }
|
|
|
|
// Chats event listeners
|
|
document.getElementById('doclib-chats-sort').addEventListener('change', (e) => { _chatsSort = e.target.value; _renderChatsGrid(); });
|
|
document.getElementById('doclib-chats-search').addEventListener('input', (e) => { _chatsSearch = e.target.value.trim(); _renderChatsGrid(); });
|
|
document.getElementById('doclib-chats-select-btn').addEventListener('click', () => { _chatsSelectMode = !_chatsSelectMode; _chatsSelected.clear(); document.getElementById('doclib-chats-bulk').classList.toggle('hidden', !_chatsSelectMode); _renderChatsGrid(); });
|
|
document.getElementById('doclib-chats-bulk-cancel')?.addEventListener('click', () => {
|
|
_chatsSelectMode = false; _chatsSelected.clear();
|
|
document.getElementById('doclib-chats-bulk').classList.add('hidden');
|
|
_renderChatsGrid();
|
|
});
|
|
function _chatsToggleAll() {
|
|
const allCb = document.getElementById('doclib-chats-select-all');
|
|
const newState = _chatsSelected.size < _chatsSessions.length;
|
|
if (allCb) allCb.checked = newState;
|
|
document.querySelectorAll('#doclib-chats-grid .memory-select-cb').forEach(cb => { cb.checked = newState; });
|
|
_chatsSessions.forEach(s => { if (newState) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); });
|
|
_updateChatsCount();
|
|
}
|
|
document.getElementById('doclib-chats-select-all').addEventListener('change', _chatsToggleAll);
|
|
document.getElementById('doclib-chats-bulk').addEventListener('click', (e) => {
|
|
if (e.target.closest('button') || e.target.closest('input')) return;
|
|
_chatsToggleAll();
|
|
});
|
|
document.getElementById('doclib-chats-bulk-archive').addEventListener('click', async () => {
|
|
const count = _chatsSelected.size;
|
|
if (!count) return;
|
|
const grid = document.getElementById('doclib-chats-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.doclib-card').forEach(card => {
|
|
const sid = card.dataset.sid || card.dataset.sessionId;
|
|
if (sid && _chatsSelected.has(sid)) {
|
|
card.style.transition = 'opacity 0.25s, transform 0.25s';
|
|
card.style.opacity = '0';
|
|
card.style.transform = 'scale(0.95)';
|
|
}
|
|
});
|
|
}
|
|
await new Promise(r => setTimeout(r, 250));
|
|
const ids = [..._chatsSelected];
|
|
const results = await Promise.all(
|
|
ids.map(sid => fetch(API_BASE + '/api/session/' + sid + '/archive', { method: 'POST', headers: {'Content-Type':'application/json'} })
|
|
.then(r => ({ sid, ok: r.ok }))
|
|
.catch(() => ({ sid, ok: false }))
|
|
)
|
|
);
|
|
const failed = results.filter(r => !r.ok).map(r => r.sid);
|
|
if (failed.length && grid) {
|
|
grid.querySelectorAll('.doclib-card').forEach(card => {
|
|
const sid = card.dataset.sid || card.dataset.sessionId;
|
|
if (sid && failed.includes(sid)) {
|
|
card.style.opacity = '';
|
|
card.style.transform = '';
|
|
}
|
|
});
|
|
if (window.uiModule) window.uiModule.showError(`Failed to archive ${failed.length} of ${ids.length} chat${ids.length > 1 ? 's' : ''}`);
|
|
}
|
|
_chatsSelected.clear();
|
|
_chatsSelectMode = false;
|
|
document.getElementById('doclib-chats-bulk').classList.add('hidden');
|
|
_renderLibChats();
|
|
});
|
|
document.getElementById('doclib-chats-bulk-delete').addEventListener('click', async () => {
|
|
const count = _chatsSelected.size;
|
|
if (!count) return;
|
|
if (!await window.styledConfirm(`Delete ${count} chat${count > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return;
|
|
// Fade out selected cards
|
|
const grid = document.getElementById('doclib-chats-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.doclib-card').forEach(card => {
|
|
const sid = card.dataset.sid || card.dataset.sessionId;
|
|
if (sid && _chatsSelected.has(sid)) {
|
|
card.style.transition = 'opacity 0.25s, transform 0.25s';
|
|
card.style.opacity = '0';
|
|
card.style.transform = 'scale(0.95)';
|
|
}
|
|
});
|
|
}
|
|
// Delete after animation. v2 review HIGH-8: inspect each response
|
|
// so cards that the server rejected get restored (instead of
|
|
// staying faded out forever) and the user sees an aggregate
|
|
// error toast.
|
|
await new Promise(r => setTimeout(r, 250));
|
|
const ids = [..._chatsSelected];
|
|
const results = await Promise.all(
|
|
ids.map(sid => fetch(API_BASE + '/api/session/' + sid, { method: 'DELETE' })
|
|
.then(r => ({ sid, ok: r.ok }))
|
|
.catch(() => ({ sid, ok: false }))
|
|
)
|
|
);
|
|
const failed = results.filter(r => !r.ok).map(r => r.sid);
|
|
if (failed.length && grid) {
|
|
// Restore faded cards for the rows the server refused.
|
|
grid.querySelectorAll('.doclib-card').forEach(card => {
|
|
const sid = card.dataset.sid || card.dataset.sessionId;
|
|
if (sid && failed.includes(sid)) {
|
|
card.style.opacity = '';
|
|
card.style.transform = '';
|
|
}
|
|
});
|
|
if (window.uiModule) window.uiModule.showError(`Failed to delete ${failed.length} of ${ids.length} chat${ids.length > 1 ? 's' : ''}`);
|
|
}
|
|
_chatsSelected.clear();
|
|
_chatsSelectMode = false;
|
|
document.getElementById('doclib-chats-bulk').classList.add('hidden');
|
|
_renderLibChats();
|
|
});
|
|
|
|
// Tidy button — AI cleanup + organize into folders
|
|
document.getElementById('doclib-chats-tidy-btn').addEventListener('click', async () => {
|
|
const tidyBtn = document.getElementById('doclib-chats-tidy-btn');
|
|
const origHTML = tidyBtn.innerHTML;
|
|
tidyBtn.disabled = true;
|
|
tidyBtn.classList.add('spinning');
|
|
tidyBtn.textContent = '';
|
|
// Silent whirlpool, nudged up to line up with the surrounding button
|
|
// text in the Chats header. The previous version checked
|
|
// `window.spinnerModule` (never bound) and always fell through to a
|
|
// plain "Tidying..." label.
|
|
const sp = spinnerModule.create('', 'clean', 'whirlpool');
|
|
const el = sp.createElement();
|
|
el.style.position = 'relative';
|
|
el.style.top = '1px';
|
|
tidyBtn.appendChild(el);
|
|
sp.start();
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/sessions/auto-sort', { method: 'POST', credentials: 'same-origin' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || 'Tidy failed');
|
|
if (data.status === 'ok') {
|
|
if (window.uiModule) window.uiModule.showToast('Sorted ' + data.updated + ' sessions into ' + data.folders.length + ' folders');
|
|
if (window.sessionModule) await window.sessionModule.loadSessions();
|
|
_renderLibChats();
|
|
} else {
|
|
if (window.uiModule) window.uiModule.showToast(data.reason || 'Nothing to tidy');
|
|
}
|
|
} catch (e) {
|
|
if (window.uiModule) window.uiModule.showError('Tidy: ' + e.message);
|
|
} finally {
|
|
tidyBtn.disabled = false;
|
|
tidyBtn.classList.remove('spinning');
|
|
tidyBtn.innerHTML = origHTML;
|
|
}
|
|
});
|
|
|
|
// ── Archive tab state ──
|
|
let _arcSessions = [];
|
|
let _arcDocs = []; // archived documents
|
|
let _arcResearch = []; // archived research reports
|
|
let _arcSearch = '';
|
|
let _arcSort = 'recent';
|
|
let _arcSelectMode = false;
|
|
const _arcSelected = new Set();
|
|
let _arcModelFilter = '';
|
|
let _arcTypeFilter = ''; // '', 'chats', 'documents', 'research'
|
|
|
|
function _renderLibArchive() {
|
|
const grid = document.getElementById('doclib-arc-grid');
|
|
if (!grid) return;
|
|
grid.innerHTML = '';
|
|
grid.appendChild(spinnerModule.createLoadingRow('Loading…'));
|
|
// Archive tab is the home for ALL archived items — chats, documents, and
|
|
// research — each rendered with its own icon. Load the three in parallel.
|
|
Promise.all([
|
|
fetch(API_BASE + '/api/sessions/archived?limit=100&sort=recent', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
|
|
fetch(API_BASE + '/api/documents/library?archived=true&limit=50', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
|
|
fetch('/api/research/library?archived=true', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
|
|
]).then(([s, d, r]) => {
|
|
// These are all archived by definition — flag them so the expanded
|
|
// chat preview hides its (redundant) "Archive" button.
|
|
_arcSessions = (s.sessions || []).map(x => ({ ...x, archived: true }));
|
|
_arcDocs = d.documents || [];
|
|
_arcResearch = (r.research || []).map(x => ({ ...x, archived: true }));
|
|
_renderArcGrid();
|
|
_renderArcChips();
|
|
}).catch(() => { grid.innerHTML = '<div class="doclib-empty">Failed to load</div>'; });
|
|
}
|
|
|
|
// Inline expand/collapse for an archived DOCUMENT card (chat-style). Loads
|
|
// the doc content into the card's .doclib-chat-preview. Lag-safe: caps the
|
|
// shown text and skips highlighting (archived previews are read-only peeks).
|
|
async function _toggleArcDocPreview(card, d) {
|
|
const preview = card.querySelector('.doclib-chat-preview');
|
|
if (!preview) return;
|
|
const grid = card.closest('.doclib-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.doclib-card-expanded').forEach(c => {
|
|
if (c !== card) {
|
|
c.classList.remove('doclib-card-expanded');
|
|
const p = c.querySelector('.doclib-chat-preview');
|
|
if (p) { p.style.display = 'none'; p.innerHTML = ''; }
|
|
}
|
|
});
|
|
}
|
|
if (card.classList.contains('doclib-card-expanded')) {
|
|
card.classList.remove('doclib-card-expanded');
|
|
preview.style.display = 'none'; preview.innerHTML = '';
|
|
return;
|
|
}
|
|
card.classList.add('doclib-card-expanded');
|
|
preview.style.display = 'block';
|
|
preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>';
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/document/${d.id}`, { credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error('failed');
|
|
const full = await res.json();
|
|
const content = (full.current_content || '').slice(0, 20000);
|
|
const pre = document.createElement('pre');
|
|
pre.style.cssText = 'white-space:pre-wrap;word-break:break-word;font-size:11px;margin:6px 4px;max-height:50vh;overflow:auto;';
|
|
pre.textContent = content || '(empty document)';
|
|
preview.innerHTML = '';
|
|
preview.appendChild(pre);
|
|
|
|
// Footer — uses the same visible .doclib-chat-preview-actions style as
|
|
// the chat/research previews (the .doclib-card-expanded-actions class is
|
|
// display:none unless inside a .doclib-card, which these archive rows
|
|
// are not). Delete + Restore, matching the others.
|
|
const actions = document.createElement('div');
|
|
actions.className = 'doclib-chat-preview-actions';
|
|
actions.innerHTML =
|
|
'<button class="doclib-chat-delete-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>' +
|
|
'<button class="doclib-chat-restore-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>Restore</button>' +
|
|
'<button class="doclib-chat-open-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>Open</button>';
|
|
actions.querySelector('.doclib-chat-delete-btn').addEventListener('click', async (ev) => {
|
|
ev.stopPropagation();
|
|
if (!await window.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true })) return;
|
|
await fetch(`${API_BASE}/api/document/${d.id}`, { method: 'DELETE', credentials: 'same-origin' });
|
|
_renderLibArchive();
|
|
});
|
|
actions.querySelector('.doclib-chat-restore-btn').addEventListener('click', async (ev) => {
|
|
ev.stopPropagation();
|
|
await fetch(`${API_BASE}/api/document/${d.id}/archive?archived=false`, { method: 'POST', credentials: 'same-origin' });
|
|
_renderLibArchive();
|
|
});
|
|
// Open = clone the doc into the active session and surface it in the editor.
|
|
actions.querySelector('.doclib-chat-open-btn').addEventListener('click', (ev) => {
|
|
ev.stopPropagation();
|
|
libraryImportDocument(d);
|
|
});
|
|
preview.appendChild(actions);
|
|
} catch {
|
|
preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Failed to load preview</div>';
|
|
}
|
|
}
|
|
|
|
function _renderArcGrid() {
|
|
const grid = document.getElementById('doclib-arc-grid');
|
|
if (!grid) return;
|
|
const _asb = document.getElementById('doclib-arc-select-btn');
|
|
if (_asb) { _asb.classList.toggle('active', _arcSelectMode); _asb.textContent = _arcSelectMode ? 'Cancel' : 'Select'; }
|
|
let filtered = _arcSessions.slice();
|
|
if (_arcSearch) {
|
|
const q = _arcSearch.toLowerCase();
|
|
filtered = filtered.filter(s => (s.name || '').toLowerCase().includes(q) || (s.model || '').toLowerCase().includes(q));
|
|
}
|
|
if (_arcModelFilter) filtered = filtered.filter(s => (s.model || '').split('/').pop() === _arcModelFilter);
|
|
if (_arcSort === 'oldest') filtered.sort((a, b) => (a.updated_at || '') > (b.updated_at || '') ? 1 : -1);
|
|
else if (_arcSort === 'most-messages') filtered.sort((a, b) => (b.message_count || 0) - (a.message_count || 0));
|
|
else if (_arcSort === 'alpha') filtered.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
else filtered.sort((a, b) => (b.updated_at || '') > (a.updated_at || '') ? 1 : -1);
|
|
|
|
// Archived documents + research also live here — filter them by the same search.
|
|
const _aq = (_arcSearch || '').toLowerCase();
|
|
let filtDocs = _aq ? _arcDocs.filter(d => (d.title || '').toLowerCase().includes(_aq)) : _arcDocs;
|
|
let filtResearch = _aq ? _arcResearch.filter(r => (r.query || '').toLowerCase().includes(_aq)) : _arcResearch;
|
|
|
|
// Type filter chips (Chats / Documents / Research) zero out the others.
|
|
const _showChats = !_arcTypeFilter || _arcTypeFilter === 'chats';
|
|
const _showDocs = !_arcTypeFilter || _arcTypeFilter === 'documents';
|
|
const _showResearch = !_arcTypeFilter || _arcTypeFilter === 'research';
|
|
if (!_showChats) filtered = [];
|
|
if (!_showDocs) filtDocs = [];
|
|
if (!_showResearch) filtResearch = [];
|
|
|
|
const stats = document.getElementById('doclib-arc-stats');
|
|
if (stats) stats.textContent = (filtered.length + filtDocs.length + filtResearch.length) + ' archived';
|
|
|
|
if (!filtered.length && !filtDocs.length && !filtResearch.length) {
|
|
// Neutral / no-smile face for "nothing archived here".
|
|
const _neutralIco = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('neutral') + '</span>';
|
|
grid.innerHTML = '<div class="doclib-empty">No archived items' + _neutralIco + '</div>';
|
|
_appendInlineLoadMore(grid, 0, _arcVisibleLimit, () => {});
|
|
return;
|
|
}
|
|
const total = filtered.length;
|
|
const visible = filtered.slice(0, _arcVisibleLimit);
|
|
grid.innerHTML = '';
|
|
_maybeCascadeGrid(grid, 'archive');
|
|
for (const s of visible) {
|
|
const card = document.createElement('div');
|
|
card.className = 'memory-item doclib-chat-row';
|
|
card.style.cursor = 'pointer';
|
|
card.dataset.sid = s.id;
|
|
card.dataset.arckey = 'chats:' + s.id;
|
|
const model = (s.model || '').split('/').pop();
|
|
const cbHtml = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="chats:' + s.id + '"' + (_arcSelected.has('chats:' + s.id) ? ' checked' : '') + '>' : '';
|
|
const arcIconSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
|
card.innerHTML =
|
|
'<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
|
|
cbHtml +
|
|
'<div style="flex:1;min-width:0;">' +
|
|
'<div class="memory-item-title">' + arcIconSvg + _esc(s.name || 'Untitled') + '</div>' +
|
|
'<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + [model, _relTime(s.updated_at)].filter(Boolean).join(' \u00b7 ') + '</div>' +
|
|
'</div>' +
|
|
'<div class="memory-item-actions"><button class="memory-item-btn _arc-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' +
|
|
'</div>' +
|
|
'<div class="doclib-chat-preview" style="display:none;"></div>';
|
|
const cb = card.querySelector('.memory-select-cb');
|
|
if (cb) { cb.addEventListener('click', e => e.stopPropagation()); cb.addEventListener('change', () => { if (cb.checked) _arcSelected.add('chats:' + s.id); else _arcSelected.delete('chats:' + s.id); _updateArcCount(); }); }
|
|
card.querySelector('._arc-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [
|
|
{ label: 'Open', action: () => { if (window.sessionModule) window.sessionModule.selectSession(s.id); } },
|
|
{ label: 'Copy', action: () => _copyChatById(s.id) },
|
|
{ label: 'Restore', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/unarchive', { method: 'POST' }); _renderLibArchive(); } },
|
|
{ label: 'Delete', action: async () => { await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' }); _renderLibArchive(); }, danger: true },
|
|
], { onSelect: () => {
|
|
_arcSelectMode = true;
|
|
_arcSelected.add('chats:' + s.id);
|
|
document.getElementById('doclib-arc-bulk')?.classList.remove('hidden');
|
|
_renderArcGrid();
|
|
} }); });
|
|
card.addEventListener('click', (e) => {
|
|
if (card._suppressNextClick) { card._suppressNextClick = false; return; }
|
|
if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('chats:' + s.id); else _arcSelected.delete('chats:' + s.id); _updateArcCount(); } return; }
|
|
if (e.target.closest('._arc-menu') || e.target.closest('.memory-select-cb') || e.target.closest('.doclib-chat-open-btn')) return;
|
|
_toggleChatPreview(card, s);
|
|
});
|
|
_attachLongPressMenu(card, '._arc-menu');
|
|
grid.appendChild(card);
|
|
}
|
|
// Archived DOCUMENTS — document icon, Restore / Delete.
|
|
const _arcDocIco = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>';
|
|
for (const d of filtDocs) {
|
|
const card = document.createElement('div');
|
|
card.className = 'memory-item doclib-chat-row';
|
|
card.style.cursor = 'pointer';
|
|
card.dataset.arckey = 'documents:' + d.id;
|
|
const _dcb = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="documents:' + d.id + '"' + (_arcSelected.has('documents:' + d.id) ? ' checked' : '') + '>' : '';
|
|
card.innerHTML =
|
|
'<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
|
|
_dcb +
|
|
'<div style="flex:1;min-width:0;">' +
|
|
'<div class="memory-item-title">' + _arcDocIco + _esc(d.title || 'Untitled') + '</div>' +
|
|
'<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + ['Document', (d.language || 'text'), _relTime(d.updated_at)].filter(Boolean).join(' · ') + '</div>' +
|
|
'</div>' +
|
|
'<div class="memory-item-actions"><button class="memory-item-btn _arc-doc-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' +
|
|
'</div>' +
|
|
'<div class="doclib-chat-preview" style="display:none;"></div>';
|
|
const _dcbEl = card.querySelector('.memory-select-cb');
|
|
if (_dcbEl) { _dcbEl.addEventListener('click', e => e.stopPropagation()); _dcbEl.addEventListener('change', () => { if (_dcbEl.checked) _arcSelected.add('documents:' + d.id); else _arcSelected.delete('documents:' + d.id); _updateArcCount(); }); }
|
|
card.addEventListener('click', (e) => {
|
|
if (e.target.closest('._arc-doc-menu') || e.target.closest('.memory-select-cb')) return;
|
|
if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('documents:' + d.id); else _arcSelected.delete('documents:' + d.id); _updateArcCount(); } return; }
|
|
_toggleArcDocPreview(card, d);
|
|
});
|
|
card.querySelector('._arc-doc-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [
|
|
{ label: 'Restore', action: async () => { await fetch(API_BASE + '/api/document/' + d.id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' }); _renderLibArchive(); } },
|
|
{ label: 'Delete', danger: true, action: async () => { if (!await window.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true })) return; await fetch(API_BASE + '/api/document/' + d.id, { method: 'DELETE', credentials: 'same-origin' }); _renderLibArchive(); } },
|
|
], { onSelect: () => {
|
|
_arcSelectMode = true;
|
|
_arcSelected.add('documents:' + d.id);
|
|
document.getElementById('doclib-arc-bulk')?.classList.remove('hidden');
|
|
_renderArcGrid();
|
|
} }); });
|
|
_attachLongPressMenu(card, '._arc-doc-menu');
|
|
grid.appendChild(card);
|
|
}
|
|
// Archived RESEARCH — magnifier icon, Open / Restore / Delete.
|
|
const _arcResIco = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
|
|
for (const r of filtResearch) {
|
|
const card = document.createElement('div');
|
|
card.className = 'memory-item doclib-chat-row';
|
|
card.style.cursor = 'pointer';
|
|
card.dataset.arckey = 'research:' + r.id;
|
|
const _rcb = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="research:' + r.id + '"' + (_arcSelected.has('research:' + r.id) ? ' checked' : '') + '>' : '';
|
|
card.innerHTML =
|
|
'<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
|
|
_rcb +
|
|
'<div style="flex:1;min-width:0;">' +
|
|
'<div class="memory-item-title">' + _arcResIco + _esc(r.query || 'Research') + '</div>' +
|
|
'<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + ['Research', (r.source_count ? r.source_count + ' sources' : ''), _relTime(r.completed_at ? new Date(r.completed_at * 1000).toISOString() : '')].filter(Boolean).join(' · ') + '</div>' +
|
|
'</div>' +
|
|
'<div class="memory-item-actions"><button class="memory-item-btn _arc-res-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' +
|
|
'</div>' +
|
|
'<div class="doclib-chat-preview" style="display:none;"></div>';
|
|
const _rcbEl = card.querySelector('.memory-select-cb');
|
|
if (_rcbEl) { _rcbEl.addEventListener('click', e => e.stopPropagation()); _rcbEl.addEventListener('change', () => { if (_rcbEl.checked) _arcSelected.add('research:' + r.id); else _arcSelected.delete('research:' + r.id); _updateArcCount(); }); }
|
|
card.addEventListener('click', (e) => {
|
|
if (e.target.closest('._arc-res-menu') || e.target.closest('.memory-select-cb')) return;
|
|
if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('research:' + r.id); else _arcSelected.delete('research:' + r.id); _updateArcCount(); } return; }
|
|
_toggleResearchPreview(card, r);
|
|
});
|
|
card.querySelector('._arc-res-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [
|
|
{ label: 'Open', action: () => { const a = document.createElement('a'); a.href = '/api/research/report/' + r.id; a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); } },
|
|
{ label: 'Restore', action: async () => { await fetch('/api/research/' + r.id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' }); _renderLibArchive(); } },
|
|
{ label: 'Delete', danger: true, action: async () => { if (!await window.styledConfirm('Delete this research?', { confirmText: 'Delete', danger: true })) return; await fetch('/api/research/' + r.id, { method: 'DELETE', credentials: 'same-origin' }); _renderLibArchive(); } },
|
|
], { onSelect: () => {
|
|
_arcSelectMode = true;
|
|
_arcSelected.add('research:' + r.id);
|
|
document.getElementById('doclib-arc-bulk')?.classList.remove('hidden');
|
|
_renderArcGrid();
|
|
} }); });
|
|
_attachLongPressMenu(card, '._arc-res-menu');
|
|
grid.appendChild(card);
|
|
}
|
|
_appendInlineLoadMore(grid, total, _arcVisibleLimit, () => {
|
|
_arcVisibleLimit += _LIB_PAGE_SIZE;
|
|
_renderArcGrid();
|
|
});
|
|
}
|
|
|
|
function _renderArcChips() {
|
|
const el = document.getElementById('doclib-arc-chips');
|
|
if (!el) return;
|
|
// Type filters: All / Chats / Documents / Research (only the ones present).
|
|
el.innerHTML = '';
|
|
const mk = (label, val, count) => {
|
|
const c = document.createElement('button');
|
|
c.className = 'memory-cat-chip' + (_arcTypeFilter === val ? ' active' : '');
|
|
c.textContent = label + ' (' + count + ')';
|
|
c.addEventListener('click', () => { _arcTypeFilter = _arcTypeFilter === val ? '' : val; _renderArcGrid(); _renderArcChips(); });
|
|
el.appendChild(c);
|
|
};
|
|
const total = _arcSessions.length + _arcDocs.length + _arcResearch.length;
|
|
if (!total) return;
|
|
mk('All', '', total);
|
|
if (_arcSessions.length) mk('Chats', 'chats', _arcSessions.length);
|
|
if (_arcDocs.length) mk('Documents', 'documents', _arcDocs.length);
|
|
if (_arcResearch.length) mk('Research', 'research', _arcResearch.length);
|
|
}
|
|
|
|
function _updateArcCount() { const el = document.getElementById('doclib-arc-selected-count'); if (el) el.textContent = _arcSelected.size + ' Selected'; }
|
|
|
|
// Archive event listeners
|
|
document.getElementById('doclib-arc-sort').addEventListener('change', (e) => { _arcSort = e.target.value; _renderArcGrid(); });
|
|
document.getElementById('doclib-arc-search').addEventListener('input', (e) => { _arcSearch = e.target.value.trim(); _renderArcGrid(); });
|
|
document.getElementById('doclib-arc-select-btn').addEventListener('click', () => { _arcSelectMode = !_arcSelectMode; _arcSelected.clear(); document.getElementById('doclib-arc-bulk').classList.toggle('hidden', !_arcSelectMode); _renderArcGrid(); });
|
|
document.getElementById('doclib-arc-bulk-cancel')?.addEventListener('click', () => {
|
|
_arcSelectMode = false; _arcSelected.clear();
|
|
document.getElementById('doclib-arc-bulk').classList.add('hidden');
|
|
_renderArcGrid();
|
|
});
|
|
// Select-all toggles EVERY visible archived card (chats + docs + research),
|
|
// keyed by the card's composite "type:id" data-arckey.
|
|
function _arcToggleAll() {
|
|
const cbs = document.querySelectorAll('#doclib-arc-grid .memory-select-cb');
|
|
const newState = _arcSelected.size < cbs.length;
|
|
const allCb = document.getElementById('doclib-arc-select-all');
|
|
if (allCb) allCb.checked = newState;
|
|
cbs.forEach(cb => {
|
|
cb.checked = newState;
|
|
const k = cb.dataset.arckey;
|
|
if (k) { if (newState) _arcSelected.add(k); else _arcSelected.delete(k); }
|
|
});
|
|
_updateArcCount();
|
|
}
|
|
document.getElementById('doclib-arc-select-all').addEventListener('change', _arcToggleAll);
|
|
document.getElementById('doclib-arc-bulk').addEventListener('click', (e) => {
|
|
if (e.target.closest('button') || e.target.closest('input')) return;
|
|
_arcToggleAll();
|
|
});
|
|
// Route a composite "type:id" key to the right restore / delete endpoint.
|
|
function _arcRestoreOne(key) {
|
|
const i = key.indexOf(':'), type = key.slice(0, i), id = key.slice(i + 1);
|
|
if (type === 'documents') return fetch(API_BASE + '/api/document/' + id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' });
|
|
if (type === 'research') return fetch('/api/research/' + id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' });
|
|
return fetch(API_BASE + '/api/session/' + id + '/unarchive', { method: 'POST', credentials: 'same-origin' });
|
|
}
|
|
function _arcDeleteOne(key) {
|
|
const i = key.indexOf(':'), type = key.slice(0, i), id = key.slice(i + 1);
|
|
if (type === 'documents') return fetch(API_BASE + '/api/document/' + id, { method: 'DELETE', credentials: 'same-origin' });
|
|
if (type === 'research') return fetch('/api/research/' + id, { method: 'DELETE', credentials: 'same-origin' });
|
|
return fetch(API_BASE + '/api/session/' + id, { method: 'DELETE', credentials: 'same-origin' });
|
|
}
|
|
document.getElementById('doclib-arc-bulk-restore').addEventListener('click', async () => {
|
|
if (!_arcSelected.size) return;
|
|
await Promise.all([..._arcSelected].map(_arcRestoreOne));
|
|
_arcSelected.clear(); _arcSelectMode = false;
|
|
document.getElementById('doclib-arc-bulk').classList.add('hidden');
|
|
_renderLibArchive();
|
|
});
|
|
document.getElementById('doclib-arc-bulk-delete').addEventListener('click', async () => {
|
|
const count = _arcSelected.size;
|
|
if (!count) return;
|
|
if (!await window.styledConfirm(`Delete ${count} archived item${count > 1 ? 's' : ''} permanently?`, { confirmText: 'Delete', danger: true })) return;
|
|
const grid = document.getElementById('doclib-arc-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.memory-item[data-arckey]').forEach(card => {
|
|
if (_arcSelected.has(card.dataset.arckey)) {
|
|
card.style.transition = 'opacity 0.25s, transform 0.25s';
|
|
card.style.opacity = '0';
|
|
card.style.transform = 'scale(0.95)';
|
|
}
|
|
});
|
|
}
|
|
await new Promise(r => setTimeout(r, 250));
|
|
await Promise.all([..._arcSelected].map(_arcDeleteOne));
|
|
_arcSelected.clear();
|
|
_arcSelectMode = false;
|
|
document.getElementById('doclib-arc-bulk').classList.add('hidden');
|
|
_renderLibArchive();
|
|
});
|
|
|
|
// ── Research tab ──
|
|
let _researchItems = [];
|
|
let _researchSearch = '';
|
|
let _researchSelectMode = false;
|
|
let _researchArchivedView = false;
|
|
const _researchSelected = new Set();
|
|
|
|
async function _renderLibResearch() {
|
|
const grid = document.getElementById('doclib-research-grid');
|
|
const stats = document.getElementById('doclib-research-stats');
|
|
if (!grid) return;
|
|
// Show our whirlpool spinner instead of the plain "Loading..." text.
|
|
grid.innerHTML = '';
|
|
try {
|
|
const _spm = (await import('./spinner.js')).default;
|
|
const _sp = _spm.createWhirlpool(22);
|
|
_sp.element.style.cssText = 'margin:18px auto;display:block;';
|
|
grid.appendChild(_sp.element);
|
|
} catch { grid.innerHTML = '<div class="hwfit-loading">Loading…</div>'; }
|
|
try {
|
|
const res = await fetch('/api/research/library' + (_researchArchivedView ? '?archived=true' : ''), { credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error(res.statusText);
|
|
const data = await res.json();
|
|
_researchItems = data.research || data || [];
|
|
} catch (e) {
|
|
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${e.message}</div>`;
|
|
return;
|
|
}
|
|
_renderResearchGrid();
|
|
}
|
|
|
|
// Toggle inline preview for a research row. Mirrors _toggleChatPreview
|
|
// but pulls research-specific metadata: query, sources list (truncated),
|
|
// followed by an "Open" action that loads the full report.
|
|
async function _toggleResearchPreview(card, item) {
|
|
const preview = card.querySelector('.doclib-chat-preview');
|
|
if (!preview) return;
|
|
const isOpen = card.classList.contains('doclib-card-expanded');
|
|
const grid = card.closest('.doclib-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.doclib-card-expanded').forEach(c => {
|
|
if (c !== card) {
|
|
c.classList.remove('doclib-card-expanded');
|
|
const p = c.querySelector('.doclib-chat-preview');
|
|
if (p) { p.style.display = 'none'; p.innerHTML = ''; }
|
|
}
|
|
});
|
|
}
|
|
if (isOpen) {
|
|
card.classList.remove('doclib-card-expanded');
|
|
preview.style.display = 'none';
|
|
preview.innerHTML = '';
|
|
return;
|
|
}
|
|
card.classList.add('doclib-card-expanded');
|
|
preview.style.display = 'block';
|
|
preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>';
|
|
let detail = item;
|
|
try {
|
|
// Hit the per-research detail endpoint to pull sources + summary.
|
|
// The library list endpoint only returns lightweight metadata.
|
|
const res = await fetch(`${API_BASE}/api/research/detail/${item.id}`, { credentials: 'same-origin' });
|
|
if (res.ok) detail = await res.json();
|
|
} catch {}
|
|
const sources = Array.isArray(detail.sources) ? detail.sources : [];
|
|
const sourcesList = sources.slice(0, 12).map((src, i) => {
|
|
const title = _esc(src.title || src.url || `Source ${i + 1}`);
|
|
const url = src.url || '';
|
|
return url
|
|
? `<li><a href="${_esc(url)}" target="_blank" rel="noopener">${title}</a></li>`
|
|
: `<li>${title}</li>`;
|
|
}).join('');
|
|
const sourcesHtml = sources.length
|
|
? `<div class="doclib-research-sources"><div class="doclib-research-section-label">Sources (${sources.length})</div><ol>${sourcesList}${sources.length > 12 ? `<li style="opacity:0.5;">…and ${sources.length - 12} more</li>` : ''}</ol></div>`
|
|
: '';
|
|
// The stored research JSON keeps the report under `result` (clean) /
|
|
// `raw_report` — there's no `summary` field, so the preview was empty.
|
|
const summary = (detail.summary || detail.report_summary || detail.result || detail.raw_report || '').toString().trim();
|
|
const summaryHtml = summary
|
|
? `<div class="doclib-research-summary"><div class="doclib-research-section-label">Report</div><div>${markdownModule.mdToHtml ? markdownModule.mdToHtml(summary) : _esc(summary)}</div></div>`
|
|
: '';
|
|
preview.innerHTML =
|
|
'<div class="doclib-chat-preview-messages">' +
|
|
(summaryHtml || sourcesHtml || '<div style="opacity:0.4;font-size:11px;padding:6px 4px;">No preview available</div>') +
|
|
(summaryHtml && sourcesHtml ? sourcesHtml : '') +
|
|
'</div>' +
|
|
'<div class="doclib-chat-preview-actions">' +
|
|
'<button class="doclib-chat-delete-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>' +
|
|
'Delete' +
|
|
'</button>' +
|
|
'<button class="doclib-chat-archive-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' +
|
|
((_researchArchivedView || item.archived) ? 'Restore' : 'Archive') +
|
|
'</button>' +
|
|
// Discuss is hidden in the Archive so the footer matches chat
|
|
// (Delete + Restore + Open).
|
|
(item.archived ? '' :
|
|
'<button class="doclib-chat-discuss-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' +
|
|
'Discuss' +
|
|
'</button>') +
|
|
'<button class="doclib-chat-open-btn">' +
|
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>' +
|
|
'Open' +
|
|
'</button>' +
|
|
'</div>';
|
|
const discussBtn = preview.querySelector('.doclib-chat-discuss-btn');
|
|
if (discussBtn) discussBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const _orig = discussBtn.innerHTML;
|
|
discussBtn.disabled = true;
|
|
discussBtn.textContent = 'Creating…';
|
|
try {
|
|
const _sid = detail.session_id || detail.id || item.id;
|
|
const res = await fetch(`${API_BASE}/api/research/spinoff/${_sid}`, { method: 'POST', credentials: 'same-origin' });
|
|
if (!res.ok) { let d = ''; try { d = (await res.json()).detail || ''; } catch {} throw new Error(d || ('HTTP ' + res.status)); }
|
|
const payload = await res.json();
|
|
if (window.sessionModule && payload.session_id) {
|
|
await window.sessionModule.loadSessions().catch(() => {});
|
|
await window.sessionModule.selectSession(payload.session_id);
|
|
}
|
|
closeLibrary();
|
|
} catch (err) {
|
|
discussBtn.disabled = false;
|
|
discussBtn.innerHTML = _orig;
|
|
if (uiModule) uiModule.showError('Could not start discussion: ' + (err.message || err));
|
|
}
|
|
});
|
|
const openBtn = preview.querySelector('.doclib-chat-open-btn');
|
|
if (openBtn) openBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const a = document.createElement('a');
|
|
a.href = '/api/research/report/' + item.id;
|
|
a.target = '_blank';
|
|
a.rel = 'noopener';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
});
|
|
const delBtn = preview.querySelector('.doclib-chat-delete-btn');
|
|
if (delBtn) delBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const ok = uiModule && uiModule.styledConfirm
|
|
? await uiModule.styledConfirm('Delete this research report?', { confirmText: 'Delete', danger: true })
|
|
: window.confirm('Delete this research report?');
|
|
if (!ok) return;
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/research/${item.id}`, { method: 'DELETE', credentials: 'same-origin' });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
if (item.archived) {
|
|
_renderLibArchive();
|
|
} else {
|
|
_researchItems = _researchItems.filter(r => r.id !== item.id);
|
|
_renderResearchGrid();
|
|
}
|
|
} catch (err) {
|
|
if (uiModule && uiModule.showError) uiModule.showError('Failed to delete: ' + err.message);
|
|
}
|
|
});
|
|
const arcBtn = preview.querySelector('.doclib-chat-archive-btn');
|
|
if (arcBtn) arcBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
// From the main Archive tab the item is already archived → Restore and
|
|
// refresh the archive. From the Research tab, toggle as before.
|
|
const fromArchiveTab = !!item.archived;
|
|
const toArchived = fromArchiveTab ? false : !_researchArchivedView;
|
|
try {
|
|
await fetch(`${API_BASE}/api/research/${item.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
|
if (fromArchiveTab) {
|
|
_renderLibArchive();
|
|
} else {
|
|
_researchItems = _researchItems.filter(r => r.id !== item.id);
|
|
_renderResearchGrid();
|
|
}
|
|
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
|
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
|
|
});
|
|
}
|
|
|
|
function _renderResearchGrid() {
|
|
const grid = document.getElementById('doclib-research-grid');
|
|
const stats = document.getElementById('doclib-research-stats');
|
|
if (!grid) return;
|
|
const _rsb = document.getElementById('doclib-research-select-btn');
|
|
if (_rsb) { _rsb.classList.toggle('active', _researchSelectMode); _rsb.textContent = _researchSelectMode ? 'Cancel' : 'Select'; }
|
|
let items = _researchItems;
|
|
if (_researchSearch) {
|
|
const s = _researchSearch.toLowerCase();
|
|
items = items.filter(r => (r.query || '').toLowerCase().includes(s));
|
|
}
|
|
// Sort
|
|
const _rSort = document.getElementById('doclib-research-sort')?.value || 'recent';
|
|
if (_rSort === 'recent') items.sort((a, b) => (b.completed_at || 0) - (a.completed_at || 0));
|
|
else if (_rSort === 'oldest') items.sort((a, b) => (a.completed_at || 0) - (b.completed_at || 0));
|
|
else if (_rSort === 'most-sources') items.sort((a, b) => (b.source_count || 0) - (a.source_count || 0));
|
|
else if (_rSort === 'alpha') items.sort((a, b) => (a.query || '').localeCompare(b.query || ''));
|
|
if (stats) stats.textContent = items.length + ' research' + (items.length !== 1 ? 'es' : '');
|
|
if (!items.length) {
|
|
grid.innerHTML =
|
|
'<div class="hwfit-loading" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' +
|
|
'<span>No research yet</span>' +
|
|
'<span style="opacity:0.7;font-size:11px;">' +
|
|
'create one in the <a href="#" data-doclib-open-research style="color:var(--accent,var(--red));text-decoration:underline;">Deep Research</a> tab' +
|
|
'</span>' +
|
|
'</div>';
|
|
grid.querySelector('[data-doclib-open-research]')?.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
document.getElementById('rail-research')?.click();
|
|
});
|
|
_appendInlineLoadMore(grid, 0, _researchVisibleLimit, () => {});
|
|
return;
|
|
}
|
|
const total = items.length;
|
|
items = items.slice(0, _researchVisibleLimit);
|
|
let html = '';
|
|
for (const r of items) {
|
|
const date = r.completed_at ? new Date(r.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
|
|
const time = r.completed_at ? new Date(r.completed_at * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '';
|
|
const sources = r.source_count || 0;
|
|
const duration = r.duration || '';
|
|
const rounds = r.rounds || '';
|
|
const selected = _researchSelected.has(r.id);
|
|
const metaBits = [];
|
|
if (date) metaBits.push(`${date} ${time}`);
|
|
if (sources) metaBits.push(`${sources} sources`);
|
|
if (rounds) metaBits.push(`${rounds} rounds`);
|
|
if (duration) metaBits.push(`${duration}`);
|
|
const metaText = metaBits.join(' \u00B7 ');
|
|
html += `<div class="memory-item doclib-chat-row doclib-research-card" data-research-id="${r.id}" style="cursor:pointer;">`;
|
|
html += `<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">`;
|
|
if (_researchSelectMode) html += `<input type="checkbox" class="memory-select-cb _res-cb" data-rid="${r.id}"${selected ? ' checked' : ''}>`;
|
|
html += `<div style="flex:1;min-width:0;">`;
|
|
html += `<div class="memory-item-title"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>${_esc(r.query || 'Untitled Research')}</div>`;
|
|
html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">${metaText}</div>`;
|
|
html += `</div>`;
|
|
if (!_researchSelectMode) html += `<div class="memory-item-actions"><button class="memory-item-btn doclib-research-delete" data-rid="${r.id}" title="Delete"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>`;
|
|
html += `</div>`;
|
|
html += `<div class="doclib-chat-preview" style="display:none;"></div>`;
|
|
html += `</div>`;
|
|
}
|
|
grid.innerHTML = html;
|
|
_maybeCascadeGrid(grid, 'research');
|
|
|
|
// Wire checkboxes
|
|
grid.querySelectorAll('._res-cb').forEach(cb => {
|
|
cb.addEventListener('click', e => e.stopPropagation());
|
|
cb.addEventListener('change', () => {
|
|
if (cb.checked) _researchSelected.add(cb.dataset.rid); else _researchSelected.delete(cb.dataset.rid);
|
|
_updateResearchCount();
|
|
});
|
|
});
|
|
|
|
// Click card → toggle preview (chat-style expand). The menu button
|
|
// and Open-report button inside the preview are exempt.
|
|
grid.querySelectorAll('.doclib-research-card').forEach(card => {
|
|
card.addEventListener('click', (e) => {
|
|
if (card._suppressNextClick) { card._suppressNextClick = false; return; }
|
|
if (e.target.closest('.doclib-research-delete') || e.target.closest('._res-cb') || e.target.closest('.doclib-chat-open-btn')) return;
|
|
const rid = card.dataset.researchId;
|
|
if (_researchSelectMode) {
|
|
const cb = card.querySelector('._res-cb');
|
|
if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
|
|
return;
|
|
}
|
|
const item = _researchItems.find(r => r.id === rid);
|
|
if (item) _toggleResearchPreview(card, item);
|
|
});
|
|
_attachLongPressMenu(card, '.doclib-research-delete');
|
|
});
|
|
|
|
// The action button on each research row opens the actions menu
|
|
// (Open report, Delete) — chat-style ••• menu.
|
|
grid.querySelectorAll('.doclib-research-delete').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const rid = btn.dataset.rid;
|
|
_showLibDropdown(btn, [
|
|
{ label: 'Open', action: () => {
|
|
const a = document.createElement('a');
|
|
a.href = '/api/research/report/' + rid;
|
|
a.target = '_blank';
|
|
a.rel = 'noopener';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
} },
|
|
{ label: _researchArchivedView ? 'Restore' : 'Archive', action: async () => {
|
|
const toArchived = !_researchArchivedView;
|
|
const card = btn.closest('.doclib-research-card');
|
|
if (card) { card.style.transition = 'opacity 0.25s, transform 0.25s'; card.style.opacity = '0'; card.style.transform = 'scale(0.95)'; }
|
|
try { await fetch('/api/research/' + rid + '/archive?archived=' + toArchived, { method: 'POST', credentials: 'same-origin' }); } catch {}
|
|
await new Promise(r => setTimeout(r, 200));
|
|
_researchItems = _researchItems.filter(r => r.id !== rid);
|
|
_renderResearchGrid();
|
|
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
|
} },
|
|
{ label: 'Delete', danger: true, action: async () => {
|
|
if (!await window.styledConfirm('Delete this research?', { confirmText: 'Delete', danger: true })) return;
|
|
const card = btn.closest('.doclib-research-card');
|
|
if (card) {
|
|
card.style.transition = 'opacity 0.25s, transform 0.25s';
|
|
card.style.opacity = '0';
|
|
card.style.transform = 'scale(0.95)';
|
|
}
|
|
await new Promise(r => setTimeout(r, 250));
|
|
await fetch('/api/research/' + rid, { method: 'DELETE', credentials: 'same-origin' });
|
|
_researchItems = _researchItems.filter(r => r.id !== rid);
|
|
_renderResearchGrid();
|
|
} },
|
|
], { onSelect: () => {
|
|
_researchSelectMode = true;
|
|
_researchSelected.add(rid);
|
|
document.getElementById('doclib-research-bulk')?.classList.remove('hidden');
|
|
_renderResearchGrid();
|
|
} });
|
|
});
|
|
});
|
|
_appendInlineLoadMore(grid, total, _researchVisibleLimit, () => {
|
|
_researchVisibleLimit += _LIB_PAGE_SIZE;
|
|
_renderResearchGrid();
|
|
});
|
|
}
|
|
|
|
// Research sort + search
|
|
const researchSortEl = document.getElementById('doclib-research-sort');
|
|
if (researchSortEl) researchSortEl.addEventListener('change', () => _renderResearchGrid());
|
|
const researchSearchEl = document.getElementById('doclib-research-search');
|
|
if (researchSearchEl) {
|
|
researchSearchEl.addEventListener('input', () => {
|
|
_researchSearch = researchSearchEl.value.trim();
|
|
_renderResearchGrid();
|
|
});
|
|
}
|
|
|
|
function _updateResearchCount() {
|
|
const el = document.getElementById('doclib-research-selected-count');
|
|
if (el) el.textContent = _researchSelected.size + ' Selected';
|
|
const arc = document.getElementById('doclib-research-bulk-archive');
|
|
if (arc) arc.textContent = _researchArchivedView ? 'Restore' : 'Archive';
|
|
}
|
|
|
|
// Research select mode
|
|
document.getElementById('doclib-research-select-btn')?.addEventListener('click', () => {
|
|
_researchSelectMode = !_researchSelectMode;
|
|
_researchSelected.clear();
|
|
document.getElementById('doclib-research-bulk').classList.toggle('hidden', !_researchSelectMode);
|
|
_renderResearchGrid();
|
|
});
|
|
|
|
// Research tidy — delete reports that came back empty (no sources, or
|
|
// empty report body). Matches the Chats tidy whirlpool/borderless pattern
|
|
// and skips confirmation per user request.
|
|
document.getElementById('doclib-research-tidy-btn')?.addEventListener('click', async (e) => {
|
|
const btn = e.currentTarget;
|
|
const origHTML = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.classList.add('spinning');
|
|
btn.textContent = '';
|
|
const sp = spinnerModule.create('', 'clean', 'whirlpool');
|
|
const el = sp.createElement();
|
|
el.style.position = 'relative';
|
|
el.style.top = '1px';
|
|
btn.appendChild(el);
|
|
sp.start();
|
|
try {
|
|
const candidates = [];
|
|
const needFetch = [];
|
|
for (const r of _researchItems) {
|
|
if ((r.source_count || 0) === 0) candidates.push(r);
|
|
else needFetch.push(r);
|
|
}
|
|
const results = await Promise.all(needFetch.map(async r => {
|
|
try {
|
|
const res = await fetch('/api/research/detail/' + r.id, { credentials: 'same-origin' });
|
|
if (!res.ok) return null;
|
|
const d = await res.json();
|
|
// Backend JSON uses `result` (rendered) or `raw_report` (raw md).
|
|
// If neither exists or both are tiny, treat as empty.
|
|
const body = (d.result || d.raw_report || '').trim();
|
|
return body.length < 200 ? r : null;
|
|
} catch { return null; }
|
|
}));
|
|
for (const r of results) if (r) candidates.push(r);
|
|
if (candidates.length === 0) {
|
|
if (uiModule) uiModule.showToast('Nothing to tidy');
|
|
return;
|
|
}
|
|
await Promise.all(candidates.map(r => fetch('/api/research/' + r.id, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {})));
|
|
const ids = new Set(candidates.map(r => r.id));
|
|
_researchItems = _researchItems.filter(r => !ids.has(r.id));
|
|
_renderResearchGrid();
|
|
if (uiModule) uiModule.showToast('Deleted ' + candidates.length);
|
|
} finally {
|
|
sp.stop();
|
|
btn.disabled = false;
|
|
btn.classList.remove('spinning');
|
|
btn.innerHTML = origHTML;
|
|
}
|
|
});
|
|
document.getElementById('doclib-research-archived-btn')?.addEventListener('click', (e) => {
|
|
_researchArchivedView = !_researchArchivedView;
|
|
e.currentTarget.classList.toggle('active', _researchArchivedView);
|
|
e.currentTarget.title = _researchArchivedView ? 'Show active research' : 'Show archived research';
|
|
if (_researchSelectMode) { _researchSelectMode = false; _researchSelected.clear(); document.getElementById('doclib-research-bulk').classList.add('hidden'); }
|
|
_renderLibResearch();
|
|
});
|
|
document.getElementById('doclib-research-bulk-cancel')?.addEventListener('click', () => {
|
|
_researchSelectMode = false;
|
|
_researchSelected.clear();
|
|
document.getElementById('doclib-research-bulk').classList.add('hidden');
|
|
_renderResearchGrid();
|
|
});
|
|
|
|
// Research select all
|
|
document.getElementById('doclib-research-select-all')?.addEventListener('change', () => {
|
|
const allCb = document.getElementById('doclib-research-select-all');
|
|
const newState = allCb?.checked;
|
|
_researchItems.forEach(r => { if (newState) _researchSelected.add(r.id); else _researchSelected.delete(r.id); });
|
|
_updateResearchCount();
|
|
_renderResearchGrid();
|
|
});
|
|
|
|
// Research bulk delete
|
|
document.getElementById('doclib-research-bulk-delete')?.addEventListener('click', async () => {
|
|
const count = _researchSelected.size;
|
|
if (!count) return;
|
|
if (!await window.styledConfirm(`Delete ${count} research report${count > 1 ? 's' : ''} permanently?`, { confirmText: 'Delete', danger: true })) return;
|
|
const grid = document.getElementById('doclib-research-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.doclib-research-card').forEach(card => {
|
|
if (_researchSelected.has(card.dataset.researchId)) {
|
|
card.style.transition = 'opacity 0.25s, transform 0.25s';
|
|
card.style.opacity = '0';
|
|
card.style.transform = 'scale(0.95)';
|
|
}
|
|
});
|
|
}
|
|
await new Promise(r => setTimeout(r, 250));
|
|
await Promise.all([..._researchSelected].map(rid => fetch('/api/research/' + rid, { method: 'DELETE', credentials: 'same-origin' })));
|
|
_researchItems = _researchItems.filter(r => !_researchSelected.has(r.id));
|
|
_researchSelected.clear();
|
|
_researchSelectMode = false;
|
|
document.getElementById('doclib-research-bulk').classList.add('hidden');
|
|
_renderResearchGrid();
|
|
});
|
|
|
|
// Research bulk archive / restore
|
|
document.getElementById('doclib-research-bulk-archive')?.addEventListener('click', async () => {
|
|
const count = _researchSelected.size;
|
|
if (!count) return;
|
|
const toArchived = !_researchArchivedView;
|
|
const grid = document.getElementById('doclib-research-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.doclib-research-card').forEach(card => {
|
|
if (_researchSelected.has(card.dataset.researchId)) {
|
|
card.style.transition = 'opacity 0.25s, transform 0.25s';
|
|
card.style.opacity = '0';
|
|
card.style.transform = 'scale(0.95)';
|
|
}
|
|
});
|
|
}
|
|
await new Promise(r => setTimeout(r, 250));
|
|
await Promise.all([..._researchSelected].map(rid => fetch('/api/research/' + rid + '/archive?archived=' + toArchived, { method: 'POST', credentials: 'same-origin' })));
|
|
_researchItems = _researchItems.filter(r => !_researchSelected.has(r.id));
|
|
_researchSelected.clear();
|
|
_researchSelectMode = false;
|
|
document.getElementById('doclib-research-bulk').classList.add('hidden');
|
|
_renderResearchGrid();
|
|
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
|
});
|
|
|
|
// Shared dropdown for chats/archive menus — defined at module scope below
|
|
// (was here originally; hoisted so libraryCreateCard's mobile kebab
|
|
// handler — which lives outside openLibrary's closure — can call it).
|
|
|
|
function _relTime(iso) {
|
|
if (!iso) return '';
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 60) return mins + 'm ago';
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return hrs + 'h ago';
|
|
const days = Math.floor(hrs / 24);
|
|
if (days < 30) return days + 'd ago';
|
|
return new Date(iso).toLocaleDateString();
|
|
}
|
|
|
|
// Switch to initial tab if not documents
|
|
if (_activeLibTab !== 'documents') _switchLibTab(_activeLibTab);
|
|
|
|
const searchInput = document.getElementById('doclib-search');
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(_librarySearchDebounce);
|
|
_librarySearchDebounce = setTimeout(() => {
|
|
_librarySearch = searchInput.value.trim();
|
|
libraryFetch(false);
|
|
}, 300);
|
|
});
|
|
|
|
document.getElementById('doclib-sort').addEventListener('change', (e) => {
|
|
_librarySort = e.target.value;
|
|
libraryFetch(false);
|
|
});
|
|
|
|
document.getElementById('doclib-load-more').addEventListener('click', () => {
|
|
_libraryOffset = _libraryDocs.length;
|
|
libraryFetch(true);
|
|
});
|
|
|
|
// Show "Load more" only when scrolled near bottom
|
|
const grid = document.getElementById('doclib-grid');
|
|
if (grid) {
|
|
grid.addEventListener('scroll', () => libraryRenderLoadMore());
|
|
// Auto-fill on resize (fullscreen toggle, window resize, sidebar
|
|
// toggle): re-run the load-more check so newly-revealed empty
|
|
// space below the last card pulls in the next page automatically.
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
new ResizeObserver(() => libraryRenderLoadMore()).observe(grid);
|
|
}
|
|
}
|
|
|
|
// Wire file import button
|
|
const importFileBtn = document.getElementById('doclib-import-file-btn');
|
|
const fileInput = document.getElementById('doclib-file-input');
|
|
if (importFileBtn && fileInput) {
|
|
importFileBtn.addEventListener('click', () => fileInput.click());
|
|
fileInput.addEventListener('change', async () => {
|
|
if (fileInput.files.length === 0) return;
|
|
const files = Array.from(fileInput.files);
|
|
fileInput.value = '';
|
|
// Swap the import icon for a whirlpool while files upload.
|
|
const _orig = importFileBtn.innerHTML;
|
|
importFileBtn.disabled = true;
|
|
let _sp = null;
|
|
try {
|
|
_sp = spinnerModule.createWhirlpool(12);
|
|
_sp.element.style.cssText = 'width:12px;height:12px;margin:0 4px 0 0;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
|
|
importFileBtn.innerHTML = '';
|
|
importFileBtn.appendChild(_sp.element);
|
|
importFileBtn.appendChild(document.createTextNode('Import'));
|
|
} catch {}
|
|
try {
|
|
await libraryImportFiles(files);
|
|
} finally {
|
|
try { _sp && _sp.stop(); } catch {}
|
|
importFileBtn.innerHTML = _orig;
|
|
importFileBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Create button — new blank document
|
|
const createBtn = document.getElementById('doclib-create-btn');
|
|
if (createBtn) {
|
|
createBtn.addEventListener('click', async () => {
|
|
// Create a new session, then create a blank document in it
|
|
try {
|
|
const sRes = await fetch('/api/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled Document' }) });
|
|
const sData = await sRes.json();
|
|
const sessionId = sData.session_id;
|
|
await _createDocument(sessionId);
|
|
// Close library and open the new session
|
|
closeLibrary();
|
|
if (window.sessionsModule) window.sessionsModule.loadSession(sessionId);
|
|
setTimeout(() => _openPanel(), 300);
|
|
} catch (e) {
|
|
console.error('Failed to create document:', e);
|
|
if (uiModule) uiModule.showError('Failed to create document');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Archived toggle — flip the Documents list between active and archived.
|
|
const archivedBtn = document.getElementById('doclib-archived-btn');
|
|
if (archivedBtn) archivedBtn.addEventListener('click', () => {
|
|
_libraryArchivedView = !_libraryArchivedView;
|
|
archivedBtn.classList.toggle('active', _libraryArchivedView);
|
|
archivedBtn.title = _libraryArchivedView ? 'Show active documents' : 'Show archived documents';
|
|
if (_librarySelectMode) libraryExitSelectMode();
|
|
libraryFetch(false);
|
|
});
|
|
|
|
// Tidy button — remove empty/broken documents
|
|
const tidyBtn = document.getElementById('doclib-tidy-btn');
|
|
if (tidyBtn) tidyBtn.addEventListener('click', async () => {
|
|
tidyBtn.disabled = true;
|
|
tidyBtn.classList.add('spinning');
|
|
const origHTML = tidyBtn.innerHTML;
|
|
tidyBtn.textContent = '';
|
|
const spinner = spinnerModule.create('', 'clean', 'whirlpool');
|
|
const _spEl = spinner.createElement();
|
|
// Optical alignment: whirlpool reads 1px high inside the button.
|
|
_spEl.style.position = 'relative';
|
|
_spEl.style.top = '1px';
|
|
tidyBtn.appendChild(_spEl);
|
|
spinner.start();
|
|
|
|
let totalDeleted = 0;
|
|
let totalFixed = 0;
|
|
let aiMessage = '';
|
|
try {
|
|
// Phase 1: regex tidy (empty/broken docs)
|
|
const [res1] = await Promise.all([
|
|
fetch(`${API_BASE}/api/documents/tidy`, { method: 'POST' }),
|
|
new Promise(r => setTimeout(r, 600)),
|
|
]);
|
|
if (res1.ok) {
|
|
const d1 = await res1.json();
|
|
totalDeleted += d1.deleted || 0;
|
|
totalFixed += d1.fixed_titles || 0;
|
|
}
|
|
|
|
// Phase 2: AI tidy (junk/test detection)
|
|
try {
|
|
const res2 = await fetch(`${API_BASE}/api/documents/ai-tidy`, { method: 'POST' });
|
|
if (res2.ok) {
|
|
const d2 = await res2.json();
|
|
totalDeleted += d2.deleted || 0;
|
|
if (d2.message) aiMessage = d2.message;
|
|
}
|
|
} catch (_) { /* AI tidy is optional */ }
|
|
|
|
spinner.destroy();
|
|
|
|
if (totalDeleted === 0 && totalFixed === 0) {
|
|
tidyBtn.innerHTML = '<span style="opacity:0.7">Already tidy</span>';
|
|
} else {
|
|
const msg = aiMessage || `Removed ${totalDeleted} document${totalDeleted !== 1 ? 's' : ''}`;
|
|
if (uiModule) uiModule.showToast(msg);
|
|
libraryFetch(false);
|
|
}
|
|
setTimeout(() => { tidyBtn.innerHTML = origHTML; tidyBtn.disabled = false; tidyBtn.classList.remove('spinning'); }, 1500);
|
|
} catch (e) {
|
|
spinner.destroy();
|
|
console.error('Document tidy failed:', e);
|
|
if (uiModule) uiModule.showToast('Tidy failed');
|
|
tidyBtn.disabled = false;
|
|
tidyBtn.classList.remove('spinning');
|
|
tidyBtn.innerHTML = origHTML;
|
|
}
|
|
});
|
|
|
|
// Select mode
|
|
const selectBtn = document.getElementById('doclib-select-btn');
|
|
if (selectBtn) selectBtn.addEventListener('click', () => {
|
|
if (_librarySelectMode) libraryExitSelectMode();
|
|
else libraryEnterSelectMode();
|
|
});
|
|
|
|
const selectAll = document.getElementById('doclib-select-all');
|
|
if (selectAll) selectAll.addEventListener('change', libraryToggleSelectAll);
|
|
|
|
// Click anywhere in the bulk bar "All" label or count area to toggle select-all
|
|
const bulkCheckLabel = modal.querySelector('.memory-bulk-check-all');
|
|
if (bulkCheckLabel) {
|
|
bulkCheckLabel.addEventListener('click', (e) => {
|
|
if (e.target === selectAll) return; // let native checkbox handle it
|
|
e.preventDefault();
|
|
selectAll.checked = !selectAll.checked;
|
|
libraryToggleSelectAll();
|
|
});
|
|
}
|
|
const selectedCountEl = document.getElementById('doclib-selected-count');
|
|
if (selectedCountEl) {
|
|
selectedCountEl.style.cursor = 'pointer';
|
|
selectedCountEl.addEventListener('click', () => {
|
|
selectAll.checked = !selectAll.checked;
|
|
libraryToggleSelectAll();
|
|
});
|
|
}
|
|
|
|
const bulkActionsBtn = document.getElementById('doclib-bulk-actions');
|
|
if (bulkActionsBtn) bulkActionsBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (_librarySelectedIds.size === 0) {
|
|
if (uiModule) uiModule.showToast('Select documents first');
|
|
return;
|
|
}
|
|
_showLibDropdown(e.currentTarget, [
|
|
{ label: _libraryArchivedView ? 'Restore' : 'Archive', icon: _libraryArchivedView ? 'restore' : 'archive', action: libraryBulkArchive },
|
|
{ label: 'Clone', icon: 'clone', action: libraryBulkClone },
|
|
{ label: 'Export', icon: 'open', action: libraryBulkExport },
|
|
{ label: 'Delete', icon: 'delete', danger: true, action: libraryBulkDelete },
|
|
], { onCancel: libraryExitSelectMode });
|
|
});
|
|
|
|
const bulkCancelBtn = document.getElementById('doclib-bulk-cancel');
|
|
if (bulkCancelBtn) bulkCancelBtn.addEventListener('click', libraryExitSelectMode);
|
|
|
|
// Close on click outside modal content
|
|
modal.addEventListener('click', (e) => {
|
|
if (uiModule.isTouchInsideModal()) return;
|
|
if (e.target === modal) closeLibrary();
|
|
});
|
|
|
|
// Escape key
|
|
_libraryEscHandler = (e) => {
|
|
if (e.key === 'Escape') {
|
|
// Collapse expanded card first, then close modal on second Escape
|
|
const expanded = document.querySelector('#doclib-grid .doclib-card-expanded');
|
|
if (expanded) {
|
|
_collapseExpandedCard(expanded);
|
|
} else {
|
|
closeLibrary();
|
|
}
|
|
}
|
|
};
|
|
document.addEventListener('keydown', _libraryEscHandler);
|
|
|
|
// Toggle active on tool button
|
|
const btn = document.getElementById('tool-doclib-btn');
|
|
if (btn) btn.classList.add('active');
|
|
|
|
libraryFetch(false);
|
|
if (window.innerWidth >= 768) searchInput.focus();
|
|
}
|
|
|
|
export function closeLibrary() {
|
|
if (!_libraryOpen) return;
|
|
_libraryOpen = false;
|
|
_librarySelectMode = false;
|
|
_librarySelectedIds.clear();
|
|
_libraryImportMode = false;
|
|
clearTimeout(_librarySearchDebounce);
|
|
|
|
const modal = document.getElementById('doclib-modal');
|
|
if (modal) {
|
|
const content = modal.querySelector('.modal-content, .doclib-modal-content');
|
|
if (content) {
|
|
content.classList.add('modal-closing');
|
|
content.addEventListener('animationend', () => modal.remove(), { once: true });
|
|
setTimeout(() => { if (modal.parentElement) modal.remove(); }, 250);
|
|
} else {
|
|
modal.remove();
|
|
}
|
|
}
|
|
|
|
if (_libraryEscHandler) {
|
|
document.removeEventListener('keydown', _libraryEscHandler);
|
|
_libraryEscHandler = null;
|
|
}
|
|
|
|
const btn = document.getElementById('tool-doclib-btn');
|
|
if (btn) btn.classList.remove('active');
|
|
}
|
|
|
|
export function isLibraryOpen() {
|
|
return _libraryOpen;
|
|
}
|