define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) { // Module-level state survives view unmount/remount within a single // SPA session so a user can fire an ask, navigate elsewhere, and come // back to the answer (or to the still-spinning request) without losing // it. sessionStorage backs the last completed answer so a hard reload // also recovers it. None of this leaks to the next browser session — // ask history is intentionally ephemeral. let _activeAsk = null; // {question, topicId, promise, startedAt} while in flight let _lastAsk = null; // {question, topicId, text, sources, completedAt} after success let _activeSearch = null; // {query, kind, topicId, promise} while in flight let _lastSearch = null; // {query, kind, topicId, hits, selectedIdx, completedAt} let _topics = null; // [{id, slug, name, description, is_active}] let _topicsPromise = null; // dedupe concurrent fetches across mounts const SS_ASK = 'kb-last-ask'; const SS_SEARCH = 'kb-last-search'; const LS_SPLIT = 'kb-split-pct'; // long-lived UI preference (not session) const LS_TOPIC = 'kb-topic'; // selected topic id, persists across sessions // Cached results auto-expire after this long. The user explicitly asked // for a clear button, but auto-clearing also handles the case where they // come back hours later and the prior result is no longer relevant. const STALE_AFTER_MS = 30 * 60 * 1000; function _loadJson(storage, key, validator) { try { const raw = storage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw); if (parsed && parsed.completedAt && (Date.now() - parsed.completedAt) > STALE_AFTER_MS) { storage.removeItem(key); return null; } return validator(parsed) ? parsed : null; } catch (e) { return null; } } function _saveJson(storage, key, value) { try { storage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota or disabled, fine */ } } function _clear(storage, key) { try { storage.removeItem(key); } catch (e) { /* ignore */ } } if (!_lastAsk) { _lastAsk = _loadJson(sessionStorage, SS_ASK, x => x && typeof x.question === 'string'); } if (!_lastSearch) { _lastSearch = _loadJson(sessionStorage, SS_SEARCH, x => x && typeof x.query === 'string' && Array.isArray(x.hits)); } function _readPersistedTopicId() { try { const raw = localStorage.getItem(LS_TOPIC); if (!raw) return null; const v = parseInt(raw, 10); return Number.isFinite(v) ? v : null; } catch (e) { return null; } } function _writePersistedTopicId(id) { try { if (id == null) localStorage.removeItem(LS_TOPIC); else localStorage.setItem(LS_TOPIC, String(id)); } catch (e) { /* ignore */ } } return Dep.extend({ template: 'knowledge-base:kb/index', // Active mode: 'search' (default) | 'ask' | 'browse' mode: 'search', kind: 'any', topicId: null, data: function () { return { mode: this.mode, kind: this.kind, topicId: this.topicId, }; }, events: { 'click [data-action="switchMode"]': function (e) { e.preventDefault(); const target = $(e.currentTarget).data('mode'); if (target === this.mode) return; this.mode = target; this.reRender(); }, 'click [data-action="submit"]': function (e) { if (e && e.preventDefault) e.preventDefault(); this.runQuery(); }, 'keypress input[data-name="query"]': function (e) { if (e.which === 13) { e.preventDefault(); this.runQuery(); } }, 'change select[data-name="kind"]': function (e) { this.kind = $(e.currentTarget).val(); }, 'click [data-action="viewSource"]': function (e) { e.preventDefault(); const id = parseInt($(e.currentTarget).data('id'), 10); if (id) this.openSource(id); }, 'click [data-action="clearResults"]': function (e) { e.preventDefault(); this.clearResults(); }, 'change select[data-name="topic"]': function (e) { const raw = $(e.currentTarget).val(); const id = parseInt(raw, 10); if (!Number.isFinite(id) || id === this.topicId) return; this.switchTopic(id); }, }, // Cancel any in-flight ask/search, drop the cached last-result for // the current mode, empty the results pane, and refocus the input. // Bound to the "חיפוש חדש" / "שאלה חדשה" button as well as the // 30-min auto-expiry path in afterRender. clearResults: function () { if (this.mode === 'search') { if (_activeSearch) { // Promise will still resolve in the background, but the // .then handler short-circuits when _activeSearch !== self. _activeSearch = null; } _lastSearch = null; _clear(sessionStorage, SS_SEARCH); } else if (this.mode === 'ask') { if (_activeAsk) { try { if (_activeAsk.es) _activeAsk.es.close(); } catch (e) { /* already closed */ } _activeAsk = null; } _lastAsk = null; _clear(sessionStorage, SS_ASK); this.stopAskProgress(); } this.setLoading(false); this.$el.find('.kb-results').empty(); const $input = this.$el.find('input[data-name="query"]'); if ($input.length) { $input.val(''); $input.trigger('focus'); } }, setup: function () { // setTitle is only available on main.js; on a plain `view` we set // document.title directly and let EspoCRM's navbar pick up the // scopeName translation. const label = this.translate('KnowledgeBase', 'scopeNames'); if (label && label !== 'KnowledgeBase') { document.title = label; } // Best-effort initial topic id from localStorage. Reconciled with // the actual topics list once it loads (a stale id from a deleted // topic falls back to the first active topic). this.topicId = _readPersistedTopicId(); // Kick off the topics fetch before first render so the options with the loaded topics, reconcile // this.topicId against the active list, and fire afterRender's // mode-specific render path now that scope is known. _populateTopicPicker: function () { const $sel = this.$el.find('.kb-topic-select'); if (!$sel.length || !_topics) return; const topics = _topics.filter(t => t.is_active !== false); if (!topics.length) { $sel.html(''); return; } // Reconcile: drop persisted id if it's not in the list any more. const ids = topics.map(t => t.id); if (!ids.includes(this.topicId)) { this.topicId = topics[0].id; _writePersistedTopicId(this.topicId); } const opts = topics.map(t => { const sel = t.id === this.topicId ? ' selected' : ''; return ''; }).join(''); $sel.html(opts); // Header suffix shows the active domain so the page title stays // self-explanatory across topics. const active = topics.find(t => t.id === this.topicId); const $suffix = this.$el.find('.kb-topic-name-suffix'); if ($suffix.length) { $suffix.text(active ? ' — ' + active.name : ''); } }, // Called from the topic