app.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. // Auto-detect API URL based on current location
  2. // Works in Docker: container:8080 → http://container-hostname:8080
  3. // Works locally: localhost:8080 → http://localhost:8080
  4. function getDefaultApiUrl() {
  5. const stored = localStorage.getItem('apiUrl');
  6. if (stored) return stored;
  7. // Use current origin (protocol + hostname + port)
  8. // This works correctly in Docker and local environments
  9. return window.location.origin;
  10. }
  11. // Configuration
  12. let config = {
  13. apiUrl: getDefaultApiUrl(),
  14. apiKey: localStorage.getItem('apiKey') || '',
  15. pageSize: parseInt(localStorage.getItem('pageSize')) || 20,
  16. theme: localStorage.getItem('theme') || 'light'
  17. };
  18. // State
  19. let state = {
  20. memories: [],
  21. stats: null,
  22. currentPage: 1,
  23. totalPages: 1,
  24. currentView: 'memories',
  25. searchQuery: '',
  26. filters: {
  27. project: '',
  28. tag: '',
  29. sort: 'recent'
  30. },
  31. bulkMode: false,
  32. selectedMemories: new Set(),
  33. editingMemory: null
  34. };
  35. // API Helper
  36. async function apiCall(endpoint, options = {}) {
  37. const url = `${config.apiUrl}${endpoint}`;
  38. const headers = {
  39. 'Content-Type': 'application/json',
  40. ...(config.apiKey && { 'X-API-Key': config.apiKey })
  41. };
  42. try {
  43. const response = await fetch(url, {
  44. ...options,
  45. headers: { ...headers, ...options.headers }
  46. });
  47. if (!response.ok) {
  48. const error = await response.json().catch(() => ({ detail: 'Request failed' }));
  49. throw new Error(error.detail || `HTTP ${response.status}`);
  50. }
  51. return await response.json();
  52. } catch (error) {
  53. console.error('API Error:', error);
  54. showNotification(error.message, 'error');
  55. throw error;
  56. }
  57. }
  58. // Initialize
  59. document.addEventListener('DOMContentLoaded', () => {
  60. initializeTheme();
  61. initializeEventListeners();
  62. loadMemories();
  63. loadStats();
  64. });
  65. function initializeTheme() {
  66. document.documentElement.setAttribute('data-theme', config.theme);
  67. updateThemeButton();
  68. }
  69. function updateThemeButton() {
  70. const btn = document.getElementById('themeToggle');
  71. const isDark = config.theme === 'dark';
  72. const icon = btn.querySelector('.theme-icon');
  73. icon.textContent = isDark ? '☀' : '🌙';
  74. btn.querySelector('.label').textContent = isDark ? 'Light Mode' : 'Dark Mode';
  75. }
  76. function initializeEventListeners() {
  77. // Navigation
  78. document.querySelectorAll('.nav-item').forEach(item => {
  79. item.addEventListener('click', (e) => {
  80. e.preventDefault();
  81. switchView(item.dataset.view);
  82. });
  83. });
  84. // Theme toggle
  85. document.getElementById('themeToggle').addEventListener('click', toggleTheme);
  86. // Search
  87. document.getElementById('searchBtn').addEventListener('click', handleSearch);
  88. document.getElementById('searchInput').addEventListener('keypress', (e) => {
  89. if (e.key === 'Enter') handleSearch();
  90. });
  91. // Add memory
  92. document.getElementById('addMemoryBtn').addEventListener('click', () => openMemoryModal());
  93. // Filters
  94. document.getElementById('projectFilter').addEventListener('change', handleFilterChange);
  95. document.getElementById('tagFilter').addEventListener('change', handleFilterChange);
  96. document.getElementById('sortFilter').addEventListener('change', handleFilterChange);
  97. // Bulk actions
  98. document.getElementById('bulkSelectBtn').addEventListener('click', toggleBulkMode);
  99. document.getElementById('cancelBulkBtn').addEventListener('click', () => toggleBulkMode(false));
  100. document.getElementById('bulkTagBtn').addEventListener('click', handleBulkTag);
  101. document.getElementById('bulkDeleteBtn').addEventListener('click', handleBulkDelete);
  102. // Pagination
  103. document.getElementById('prevPage').addEventListener('click', () => changePage(-1));
  104. document.getElementById('nextPage').addEventListener('click', () => changePage(1));
  105. // Modal
  106. document.getElementById('saveMemory').addEventListener('click', handleSaveMemory);
  107. document.querySelectorAll('.modal-close').forEach(btn => {
  108. btn.addEventListener('click', closeModal);
  109. });
  110. document.getElementById('memoryText').addEventListener('input', updatePreview);
  111. // Settings
  112. document.getElementById('saveSettings').addEventListener('click', saveSettings);
  113. document.getElementById('apiUrl').value = config.apiUrl;
  114. document.getElementById('apiKey').value = config.apiKey;
  115. document.getElementById('pageSize').value = config.pageSize;
  116. }
  117. // View Management
  118. function switchView(view) {
  119. state.currentView = view;
  120. // Update nav
  121. document.querySelectorAll('.nav-item').forEach(item => {
  122. item.classList.toggle('active', item.dataset.view === view);
  123. });
  124. // Update views
  125. document.querySelectorAll('.view').forEach(v => {
  126. v.classList.toggle('active', v.id === `${view}View`);
  127. });
  128. // Load data for view
  129. if (view === 'stats') loadStats();
  130. }
  131. function toggleTheme() {
  132. config.theme = config.theme === 'light' ? 'dark' : 'light';
  133. localStorage.setItem('theme', config.theme);
  134. document.documentElement.setAttribute('data-theme', config.theme);
  135. updateThemeButton();
  136. }
  137. // Memory Management
  138. async function loadMemories() {
  139. const list = document.getElementById('memoriesList');
  140. list.innerHTML = '<div class="loading">Loading memories...</div>';
  141. try {
  142. const params = new URLSearchParams({
  143. limit: config.pageSize,
  144. offset: (state.currentPage - 1) * config.pageSize,
  145. ...(state.filters.project && { project: state.filters.project }),
  146. ...(state.filters.tag && { tag: state.filters.tag }),
  147. ...(state.searchQuery && { q: state.searchQuery })
  148. });
  149. const data = state.searchQuery
  150. ? await apiCall(`/memory/search?${params}`)
  151. : await apiCall(`/memory/list?${params}`);
  152. state.memories = data.results || data.memories || [];
  153. const total = data.total || data.total_items || 0;
  154. state.totalPages = Math.ceil(total / config.pageSize) || 1;
  155. renderMemories();
  156. updatePagination();
  157. updateFilters();
  158. } catch (error) {
  159. list.innerHTML = `<div class="empty-state"><h3>Failed to load memories</h3><p>${error.message}</p></div>`;
  160. }
  161. }
  162. function renderMemories() {
  163. const list = document.getElementById('memoriesList');
  164. if (state.memories.length === 0) {
  165. list.innerHTML = `
  166. <div class="empty-state">
  167. <h3>No memories found</h3>
  168. <p>Start by adding your first memory!</p>
  169. </div>
  170. `;
  171. return;
  172. }
  173. list.innerHTML = state.memories.map(memory => `
  174. <div class="memory-card ${state.selectedMemories.has(memory.id) ? 'selected' : ''}" data-id="${memory.id}">
  175. ${state.bulkMode ? `<input type="checkbox" class="bulk-checkbox" ${state.selectedMemories.has(memory.id) ? 'checked' : ''}>` : ''}
  176. ${memory.score ? `<span class="memory-score">${(memory.score * 100).toFixed(0)}%</span>` : ''}
  177. <div class="memory-header">
  178. <div class="memory-meta">
  179. ${memory.project ? `<span class="memory-project">${memory.project}</span>` : ''}
  180. <div class="memory-date">${formatDate(memory.created_at)}</div>
  181. </div>
  182. <div class="memory-actions">
  183. <button class="btn btn-sm btn-secondary" onclick="editMemory('${memory.id}')">Edit</button>
  184. <button class="btn btn-sm btn-danger" onclick="deleteMemory('${memory.id}')">Delete</button>
  185. </div>
  186. </div>
  187. <div class="memory-text">${escapeHtml(memory.text)}</div>
  188. ${memory.tags && memory.tags.length > 0 ? `
  189. <div class="memory-tags">
  190. ${memory.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
  191. </div>
  192. ` : ''}
  193. </div>
  194. `).join('');
  195. // Add bulk checkbox listeners
  196. if (state.bulkMode) {
  197. document.querySelectorAll('.bulk-checkbox').forEach(cb => {
  198. cb.addEventListener('change', (e) => {
  199. const id = e.target.closest('.memory-card').dataset.id;
  200. if (e.target.checked) {
  201. state.selectedMemories.add(id);
  202. } else {
  203. state.selectedMemories.delete(id);
  204. }
  205. updateBulkActions();
  206. });
  207. });
  208. }
  209. }
  210. async function handleSearch() {
  211. const query = document.getElementById('searchInput').value.trim();
  212. if (!query) {
  213. state.searchQuery = '';
  214. loadMemories();
  215. return;
  216. }
  217. state.searchQuery = query;
  218. state.currentPage = 1;
  219. loadMemories();
  220. }
  221. function handleFilterChange() {
  222. state.filters.project = document.getElementById('projectFilter').value;
  223. state.filters.tag = document.getElementById('tagFilter').value;
  224. state.filters.sort = document.getElementById('sortFilter').value;
  225. state.currentPage = 1;
  226. loadMemories();
  227. }
  228. async function updateFilters() {
  229. // Extract unique projects and tags
  230. const projects = new Set();
  231. const tags = new Set();
  232. state.memories.forEach(m => {
  233. if (m.project) projects.add(m.project);
  234. if (m.tags) m.tags.forEach(t => tags.add(t));
  235. });
  236. // Update project filter
  237. const projectFilter = document.getElementById('projectFilter');
  238. projectFilter.innerHTML = '<option value="">All Projects</option>' +
  239. Array.from(projects).sort().map(p => `<option value="${p}">${p}</option>`).join('');
  240. // Update tag filter
  241. const tagFilter = document.getElementById('tagFilter');
  242. tagFilter.innerHTML = '<option value="">All Tags</option>' +
  243. Array.from(tags).sort().map(t => `<option value="${t}">${t}</option>`).join('');
  244. }
  245. function toggleBulkMode(enable = !state.bulkMode) {
  246. state.bulkMode = enable;
  247. state.selectedMemories.clear();
  248. document.getElementById('bulkActions').classList.toggle('hidden', !enable);
  249. renderMemories();
  250. updateBulkActions();
  251. }
  252. function updateBulkActions() {
  253. document.getElementById('selectedCount').textContent = `${state.selectedMemories.size} selected`;
  254. }
  255. async function handleBulkTag() {
  256. const tags = prompt('Enter tags to add (comma-separated):');
  257. if (!tags) return;
  258. const tagArray = tags.split(',').map(t => t.trim());
  259. for (const id of state.selectedMemories) {
  260. // This would need backend support for updating tags
  261. showNotification('Bulk tagging not yet implemented', 'warning');
  262. }
  263. }
  264. async function handleBulkDelete() {
  265. if (!confirm(`Delete ${state.selectedMemories.size} memories?`)) return;
  266. try {
  267. for (const id of state.selectedMemories) {
  268. await apiCall(`/memory/${id}`, { method: 'DELETE' });
  269. }
  270. showNotification(`Deleted ${state.selectedMemories.size} memories`, 'success');
  271. toggleBulkMode(false);
  272. loadMemories();
  273. } catch (error) {
  274. // Error already shown by apiCall
  275. }
  276. }
  277. async function deleteMemory(id) {
  278. if (!confirm('Delete this memory?')) return;
  279. try {
  280. await apiCall(`/memory/${id}`, { method: 'DELETE' });
  281. showNotification('Memory deleted', 'success');
  282. loadMemories();
  283. } catch (error) {
  284. // Error already shown
  285. }
  286. }
  287. function editMemory(id) {
  288. const memory = state.memories.find(m => m.id === id);
  289. if (!memory) return;
  290. state.editingMemory = memory;
  291. document.getElementById('modalTitle').textContent = 'Edit Memory';
  292. document.getElementById('memoryText').value = memory.text;
  293. document.getElementById('memoryProject').value = memory.project || '';
  294. document.getElementById('memoryTags').value = memory.tags ? memory.tags.join(', ') : '';
  295. document.getElementById('autoTag').checked = false;
  296. updatePreview();
  297. openMemoryModal();
  298. }
  299. // Modal Management
  300. function openMemoryModal() {
  301. document.getElementById('memoryModal').classList.add('active');
  302. }
  303. function closeModal() {
  304. document.getElementById('memoryModal').classList.remove('active');
  305. document.getElementById('memoryText').value = '';
  306. document.getElementById('memoryProject').value = '';
  307. document.getElementById('memoryTags').value = '';
  308. document.getElementById('autoTag').checked = true;
  309. document.getElementById('modalTitle').textContent = 'Add Memory';
  310. state.editingMemory = null;
  311. }
  312. function updatePreview() {
  313. const text = document.getElementById('memoryText').value;
  314. document.getElementById('memoryPreview').textContent = text || 'Preview will appear here...';
  315. }
  316. async function handleSaveMemory() {
  317. const text = document.getElementById('memoryText').value.trim();
  318. if (!text) {
  319. showNotification('Please enter memory text', 'error');
  320. return;
  321. }
  322. const project = document.getElementById('memoryProject').value.trim();
  323. const tagsInput = document.getElementById('memoryTags').value.trim();
  324. const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()) : [];
  325. const autoTag = document.getElementById('autoTag').checked && !tagsInput;
  326. const payload = {
  327. text,
  328. ...(project && { project }),
  329. ...(!autoTag && tags.length > 0 && { tags })
  330. };
  331. try {
  332. if (state.editingMemory) {
  333. // Update would need PATCH endpoint - not implemented yet
  334. showNotification('Edit not yet implemented', 'warning');
  335. } else {
  336. await apiCall('/memory/save', {
  337. method: 'POST',
  338. body: JSON.stringify(payload)
  339. });
  340. showNotification('Memory saved!', 'success');
  341. closeModal();
  342. loadMemories();
  343. }
  344. } catch (error) {
  345. // Error already shown
  346. }
  347. }
  348. // Statistics
  349. async function loadStats() {
  350. try {
  351. const data = await apiCall('/memory/stats');
  352. state.stats = data;
  353. renderStats();
  354. } catch (error) {
  355. console.error('Failed to load stats:', error);
  356. }
  357. }
  358. function renderStats() {
  359. if (!state.stats) return;
  360. document.getElementById('totalMemories').textContent = state.stats.total_memories || 0;
  361. document.getElementById('totalProjects').textContent = state.stats.total_projects || 0;
  362. document.getElementById('totalTags').textContent = state.stats.total_tags || 0;
  363. document.getElementById('avgSize').textContent = state.stats.avg_text_length
  364. ? `${state.stats.avg_text_length.toFixed(0)} chars`
  365. : '-';
  366. renderProjectChart();
  367. renderTagCloud();
  368. }
  369. function renderProjectChart() {
  370. if (!state.stats.memories_by_project) return;
  371. const chart = document.getElementById('projectChart');
  372. const projects = Object.entries(state.stats.memories_by_project)
  373. .sort((a, b) => b[1] - a[1])
  374. .slice(0, 10);
  375. const maxCount = Math.max(...projects.map(([_, count]) => count));
  376. chart.innerHTML = projects.map(([project, count]) => {
  377. const width = (count / maxCount) * 100;
  378. return `
  379. <div class="chart-bar">
  380. <span class="chart-label">${project}</span>
  381. <div class="chart-bar-fill" style="width: ${width}%">
  382. <span class="chart-value">${count}</span>
  383. </div>
  384. </div>
  385. `;
  386. }).join('');
  387. }
  388. function renderTagCloud() {
  389. if (!state.stats.tags_distribution) return;
  390. const cloud = document.getElementById('tagCloud');
  391. const tags = Object.entries(state.stats.tags_distribution)
  392. .sort((a, b) => b[1] - a[1])
  393. .slice(0, 30);
  394. const maxCount = Math.max(...tags.map(([_, count]) => count));
  395. cloud.innerHTML = tags.map(([tag, count]) => {
  396. let sizeClass = '';
  397. const ratio = count / maxCount;
  398. if (ratio > 0.7) sizeClass = 'large';
  399. else if (ratio > 0.4) sizeClass = 'medium';
  400. return `<span class="tag-cloud-item ${sizeClass}" title="${count} memories">${tag}</span>`;
  401. }).join('');
  402. }
  403. // Settings
  404. function saveSettings() {
  405. config.apiUrl = document.getElementById('apiUrl').value.trim();
  406. config.apiKey = document.getElementById('apiKey').value.trim();
  407. config.pageSize = parseInt(document.getElementById('pageSize').value);
  408. localStorage.setItem('apiUrl', config.apiUrl);
  409. localStorage.setItem('apiKey', config.apiKey);
  410. localStorage.setItem('pageSize', config.pageSize);
  411. showNotification('Settings saved!', 'success');
  412. loadMemories();
  413. }
  414. // Pagination
  415. function changePage(delta) {
  416. const newPage = state.currentPage + delta;
  417. // Prevent navigation if no valid pages
  418. if (state.totalPages <= 0) return;
  419. if (newPage < 1 || newPage > state.totalPages) return;
  420. state.currentPage = newPage;
  421. loadMemories();
  422. }
  423. function updatePagination() {
  424. // Ensure at least 1 page if totalPages is 0
  425. const displayPages = Math.max(state.totalPages, state.memories.length > 0 ? 1 : 0);
  426. document.getElementById('pageInfo').textContent = `Page ${state.currentPage} of ${displayPages}`;
  427. // Only disable buttons if we have valid pages to show
  428. const hasPages = displayPages > 0;
  429. document.getElementById('prevPage').disabled = !hasPages || state.currentPage === 1;
  430. document.getElementById('nextPage').disabled = !hasPages || state.currentPage === displayPages;
  431. }
  432. // Utilities
  433. function formatDate(timestamp) {
  434. if (!timestamp) return '';
  435. const date = new Date(timestamp);
  436. const now = new Date();
  437. const diff = now - date;
  438. const days = Math.floor(diff / (1000 * 60 * 60 * 24));
  439. if (days === 0) return 'Today';
  440. if (days === 1) return 'Yesterday';
  441. if (days < 7) return `${days} days ago`;
  442. return date.toLocaleDateString();
  443. }
  444. function escapeHtml(text) {
  445. const div = document.createElement('div');
  446. div.textContent = text;
  447. return div.innerHTML;
  448. }
  449. function showNotification(message, type = 'info') {
  450. // Simple alert for now - could be replaced with toast notifications
  451. const prefix = type === 'error' ? 'Error: ' : type === 'success' ? 'Success: ' : 'Info: ';
  452. alert(prefix + message);
  453. }