This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
KnowledgeBase/files/client/custom/modules/knowledge-base/src/views/kb/index.js
T
chaim ba75463661 feat(kb): topic picker + topic-scoped client (Phase 1, no manifest bump)
Wires the EspoCRM extension to the multi-topic backend introduced in
shira-hermes commit f03721e. The KB page header now shows a topic
<select> ('נושא:'); the user's selection persists in localStorage
('kb-topic') and is sent with every search/ask/sources call so cross-
domain bleed-over is impossible. Single seeded topic ('ביטוח לאומי')
means existing users see no functional change until a second topic is
added in Phase 4 (Task Master #15).

- Resources/routes.json: GET /KnowledgeBase/action/topics.
- Controllers/KnowledgeBase.php: actionTopics + topicId passthrough on
  search/sources/ask. Numeric coercion for the request payload.
- Services/KnowledgeBaseService.php: listTopics(); search/listSources/
  ask take an optional topicId and forward it as topic_id to upstream.
- EntryPoints/KnowledgeBaseAskStream.php: ?topicId=N becomes topic_id
  in the JSON body sent to /kb/ask/stream.
- res/templates/kb/index.tpl: topic <select> in the page header,
  with a kb-topic-name-suffix span that JS fills with the active name.
- src/views/kb/index.js: module-level _topics cache + LS_TOPIC. setup
  reads localStorage; afterRender gates the picker fill + sources fetch
  on topics being loaded so a stale id from a deleted topic doesn't
  fire a 400-ing /sources call. switchTopic clears _activeAsk/Search +
  _lastAsk/Search + sessionStorage entries + this._sources, then
  reRenders. Cross-topic guard in afterRender drops cached state whose
  topicId !== this.topicId. _activeAsk / _lastAsk / _activeSearch /
  _lastSearch all carry topicId. EventSource URL gains &topicId.

Refs Task Master #12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:15:36 +00:00

1149 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <select>
// can be populated in the first afterRender pass without flicker.
this._ensureTopicsLoaded();
},
_ensureSourcesLoaded: function () {
const self = this;
if (this._sources) {
this.renderSourcesList();
return;
}
if (this._sourcesPromise || this.topicId == null) return;
const topicAtFetch = this.topicId;
this._sourcesPromise = Espo.Ajax.getRequest('KnowledgeBase/action/sources', {topicId: topicAtFetch})
.then(res => {
// Bail if the user switched topic before we landed.
if (self.topicId !== topicAtFetch) return;
self._sources = res.sources || [];
self.renderSourcesList();
})
.catch(err => {
console.error('KB: failed to load sources', err);
});
},
_ensureTopicsLoaded: function () {
if (_topics) return Promise.resolve(_topics);
if (_topicsPromise) return _topicsPromise;
_topicsPromise = Espo.Ajax.getRequest('KnowledgeBase/action/topics')
.then(res => {
_topics = (res && res.topics) || [];
return _topics;
})
.catch(err => {
console.error('KB: failed to load topics', err);
_topics = [];
return _topics;
})
.finally(() => { _topicsPromise = null; });
return _topicsPromise;
},
// Replace the <select> 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('<option value="">אין נושאים פעילים</option>');
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 '<option value="' + t.id + '"' + sel + '>' +
this.escape(t.name || ('#' + t.id)) + '</option>';
}).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 <select>'s change handler. Tear down anything
// scoped to the prior topic so cross-topic results never bleed in.
switchTopic: function (newTopicId) {
this.topicId = newTopicId;
_writePersistedTopicId(newTopicId);
// Invalidate everything scoped to the old topic.
_activeAsk = null;
_activeSearch = null;
_lastAsk = null;
_lastSearch = null;
_clear(sessionStorage, SS_ASK);
_clear(sessionStorage, SS_SEARCH);
this._sources = null;
this._sourcesPromise = null;
this.stopAskProgress();
this.setLoading(false);
this.$el.find('.kb-results').empty();
// Re-render the page so mode-specific markup picks up the new
// topic header. afterRender will re-fetch /sources for it.
this.reRender();
},
afterRender: function () {
const self = this;
// Populate picker + scope-dependent fetches gate on topics being
// loaded so a stale localStorage id (deleted topic) doesn't fire
// a /sources?topicId=99 that 400s. Once topics resolve, the
// .then path reconciles this.topicId then runs the fetch.
const afterTopics = () => {
if (!self.$el || !self.$el.length) return;
self._populateTopicPicker();
self._ensureSourcesLoaded();
};
if (_topics) {
afterTopics();
} else {
this._ensureTopicsLoaded().then(afterTopics);
}
// Recover prior state across view remounts (user navigated away and
// back). Active in-flight wins; otherwise replay the last result
// from sessionStorage so the work the user already did doesn't
// disappear behind a tab switch.
//
// CROSS-TOPIC GUARD: any cached state whose topicId doesn't match
// the current topic is silently dropped — its hits/answer were
// computed against a different domain.
if (_activeAsk && _activeAsk.topicId != null && _activeAsk.topicId !== this.topicId) {
try { if (_activeAsk.es) _activeAsk.es.close(); } catch (e) { /* ignore */ }
_activeAsk = null;
}
if (_lastAsk && _lastAsk.topicId != null && _lastAsk.topicId !== this.topicId) {
_lastAsk = null;
_clear(sessionStorage, SS_ASK);
}
if (_activeSearch && _activeSearch.topicId != null && _activeSearch.topicId !== this.topicId) {
_activeSearch = null;
}
if (_lastSearch && _lastSearch.topicId != null && _lastSearch.topicId !== this.topicId) {
_lastSearch = null;
_clear(sessionStorage, SS_SEARCH);
}
if (this.mode === 'ask') {
if (_activeAsk) {
const $input = this.$el.find('input[data-name="query"]');
if ($input.length) $input.val(_activeAsk.question);
this.setLoading(true);
// Pass the whole ask so startAskProgress can replay the
// accumulated event log into the freshly-rendered DOM.
this.startAskProgress(_activeAsk);
this._attachAskHandlers(_activeAsk);
} else if (_lastAsk) {
const $input = this.$el.find('input[data-name="query"]');
if ($input.length) $input.val(_lastAsk.question);
this.renderAskAnswer(_lastAsk.text, _lastAsk.sources);
}
} else if (this.mode === 'search') {
if (_activeSearch) {
const $input = this.$el.find('input[data-name="query"]');
if ($input.length) $input.val(_activeSearch.query);
this.setLoading(true);
this._attachSearchHandlers(_activeSearch);
} else if (_lastSearch && _lastSearch.hits.length) {
const $input = this.$el.find('input[data-name="query"]');
if ($input.length) $input.val(_lastSearch.query);
const $kind = this.$el.find('select[data-name="kind"]');
if ($kind.length && _lastSearch.kind) $kind.val(_lastSearch.kind);
this.renderSearchResults(_lastSearch.hits, _lastSearch.selectedIdx || 0);
}
}
// Focus the input on every render so users can start typing right away.
const $input = this.$el.find('input[data-name="query"]');
if ($input.length) $input.trigger('focus');
},
runQuery: function () {
const query = (this.$el.find('input[data-name="query"]').val() || '').trim();
if (!query) return;
const kind = this.$el.find('select[data-name="kind"]').val() || 'any';
this.kind = kind;
if (this.mode === 'search') {
this.runSearch(query, kind);
} else if (this.mode === 'ask') {
this.runAsk(query);
}
},
runSearch: function (query, kind) {
// A new search invalidates the prior cached one.
_lastSearch = null;
_clear(sessionStorage, SS_SEARCH);
this.setLoading(true);
const topicId = this.topicId;
const promise = Espo.Ajax.postRequest('KnowledgeBase/action/search', {
query: query,
kind: kind,
topK: 8,
topicId: topicId,
});
_activeSearch = {query: query, kind: kind, topicId: topicId, promise: promise};
this._attachSearchHandlers(_activeSearch);
},
_attachSearchHandlers: function (search) {
const self = this;
search.promise.then(res => {
const hits = res.hits || [];
const entry = {
query: search.query,
kind: search.kind,
topicId: search.topicId,
hits: hits,
selectedIdx: 0,
completedAt: Date.now(),
};
_lastSearch = entry;
_saveJson(sessionStorage, SS_SEARCH, entry);
if (_activeSearch === search) _activeSearch = null;
if (self.$el && self.$el.length) {
self.setLoading(false);
if (self.mode === 'search') {
self.renderSearchResults(hits);
}
}
}).catch(err => {
if (_activeSearch === search) _activeSearch = null;
if (self.$el && self.$el.length) {
self.setLoading(false);
self.showError(err);
}
});
},
runAsk: function (message) {
// A new ask invalidates whatever last-result was on screen.
_lastAsk = null;
_clear(sessionStorage, SS_ASK);
this.setLoading(true);
const topicId = this.topicId;
// Streaming via Server-Sent Events. The browser opens an
// EventSource against KnowledgeBaseAskStream which proxies
// /kb/ask/stream on shira-hermes; one SSE event per agent step
// (thinking, tool_start, tool_done) plus a final answer event.
let url = '?entryPoint=KnowledgeBaseAskStream&message='
+ encodeURIComponent(message);
if (topicId != null) {
url += '&topicId=' + encodeURIComponent(topicId);
}
const es = new EventSource(url);
_activeAsk = {
question: message,
topicId: topicId,
es: es,
events: [],
startedAt: Date.now(),
};
this.startAskProgress(_activeAsk);
this._attachAskHandlers(_activeAsk);
},
// Wire SSE handlers on a (possibly module-level) ask. Called from
// runAsk for the initial bind and from afterRender when the user
// navigates back to the KB view while an earlier stream is still
// open. The EventSource itself lives at module scope (in _activeAsk)
// so handlers can be re-bound without dropping the connection.
_attachAskHandlers: function (ask) {
const self = this;
ask.es.onmessage = function (msgEvent) {
let data;
try { data = JSON.parse(msgEvent.data); }
catch (e) {
console.warn('KB: malformed SSE payload', e);
return;
}
if (data.type === 'answer') {
self._finishAsk(ask, {
question: ask.question,
topicId: ask.topicId,
text: data.text || '',
sources: data.sources || [],
completedAt: Date.now(),
});
} else if (data.type === 'error') {
self._failAsk(ask, data.message || 'שגיאה בשרת');
} else {
// Status event — record it and append to the live log.
ask.events.push(Object.assign({_clientTs: Date.now()}, data));
if (self.$el && self.$el.length && self.mode === 'ask') {
self._appendAskEvent(ask, ask.events[ask.events.length - 1]);
}
}
};
ask.es.onerror = function () {
// Don't tear down on transient blips while the answer is
// already arriving; only treat it as a hard failure if no
// answer has been delivered.
if (ask._completed) return;
self._failAsk(ask, 'הזרם נסגר לפני שהגיעה תשובה');
};
},
_finishAsk: function (ask, entry) {
ask._completed = true;
try { ask.es.close(); } catch (e) { /* ignore */ }
_lastAsk = entry;
_saveJson(sessionStorage, SS_ASK, entry);
if (_activeAsk === ask) _activeAsk = null;
if (this.$el && this.$el.length) {
this.stopAskProgress();
this.setLoading(false);
if (this.mode === 'ask') {
this.renderAskAnswer(entry.text, entry.sources);
}
}
},
_failAsk: function (ask, message) {
try { ask.es.close(); } catch (e) { /* ignore */ }
if (_activeAsk === ask) _activeAsk = null;
if (this.$el && this.$el.length) {
this.stopAskProgress();
this.setLoading(false);
this.showError(new Error(message));
}
},
startAskProgress: function (askOrStartedAt) {
// Render a live progress panel: a "thinking" header with elapsed
// time, plus a stacked log of the events Shira's emitted so far
// (one line per LLM step / tool call). `askOrStartedAt` is
// either the active-ask object (from runAsk and afterRender) or
// a bare timestamp (legacy call sites). Re-mounting on view
// switch replays the accumulated event list from ask.events.
if (this.mode !== 'ask') return;
this.stopAskProgress();
let ask = null;
let startedAt;
if (askOrStartedAt && typeof askOrStartedAt === 'object') {
ask = askOrStartedAt;
startedAt = ask.startedAt || Date.now();
} else {
startedAt = askOrStartedAt || Date.now();
}
const $results = this.$el.find('.kb-results');
$results.html(
'<div class="kb-ask-progress panel panel-info">' +
' <div class="panel-body" style="direction:rtl;text-align:right;">' +
' <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">' +
' <i class="fas fa-circle-notch fa-spin"></i>' +
' <strong>שירה חושבת</strong>' +
' <span class="text-muted small kb-elapsed-pill" ' +
'style="margin-right:auto;font-variant-numeric:tabular-nums;">0 שניות</span>' +
' </div>' +
' <div class="kb-events" style="' +
'border-top:1px solid #d8e0e8;padding-top:8px;' +
'max-height:60vh;overflow-y:auto;font-size:0.95em;"></div>' +
' </div>' +
'</div>'
);
// Replay any events already collected (matters when the user
// navigates away mid-think and comes back to a still-streaming
// ask — the stream kept going at module scope, but the DOM was
// freshly re-rendered).
if (ask && Array.isArray(ask.events)) {
ask.events.forEach(ev => this._appendAskEvent(ask, ev));
}
const tick = () => {
const sec = Math.round((Date.now() - startedAt) / 1000);
this.$el.find('.kb-elapsed-pill').text(sec + ' שניות');
};
tick();
this._askTimer = setInterval(tick, 1000);
},
_appendAskEvent: function (ask, ev) {
const $events = this.$el.find('.kb-events');
if (!$events.length) return;
const startedAt = (ask && ask.startedAt) || Date.now();
const ts = ev._clientTs || Date.now();
const sec = Math.max(0, Math.round((ts - startedAt) / 1000));
const icon = {
thinking: '🧠',
tool_start: '🔍',
tool_done: '✓',
tool_error: '⚠',
writing: '✍️',
}[ev.type] || '·';
const colour = {
thinking: '#475569',
tool_start: '#0f5298',
tool_done: '#1e6f40',
tool_error: '#a02622',
writing: '#5a3a91',
}[ev.type] || '#475569';
const label = this.escape(ev.label || ev.type);
const row = $(
'<div style="padding:3px 0;color:' + colour + ';display:flex;gap:8px;align-items:flex-start;">' +
' <span style="display:inline-block;min-width:40px;color:#94a3b8;font-variant-numeric:tabular-nums;">' +
sec + 'ש</span>' +
' <span style="margin:0 2px;">' + icon + '</span>' +
' <span style="flex:1 1 auto;word-break:break-word;">' + label + '</span>' +
'</div>'
);
$events.append(row);
// Always keep the latest line visible.
$events.scrollTop($events[0].scrollHeight);
},
stopAskProgress: function () {
if (this._askTimer) {
clearInterval(this._askTimer);
this._askTimer = null;
}
},
renderSearchResults: function (hits, selectedIdx) {
const $results = this.$el.find('.kb-results');
if (!hits.length) {
$results.html('<div class="text-muted">לא נמצאו תוצאות.</div>');
return;
}
const initialIdx = (typeof selectedIdx === 'number' && hits[selectedIdx]) ? selectedIdx : 0;
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר'};
const self = this;
const isPdfBacked = h => h && (h.kind === 'regulation' || h.kind === 'circular');
// Left column: stacked hit cards. Click → preview on the right.
const bodyStyle =
'direction:rtl;unicode-bidi:plaintext;white-space:pre-wrap;' +
'font-family:"Segoe UI",Arial,sans-serif;line-height:1.6;text-align:right;' +
'max-height:14em;overflow:hidden;';
const listHtml = hits.map((h, i) => {
const kind = kindHe[h.kind] || h.kind;
const ident = h.identifier ? ' ' + this.escape(h.identifier) : '';
const path = h.heading_path || h.section_ref || '';
const pub = h.published_at ? ' · פורסם ' + h.published_at : '';
const pageLabel = (isPdfBacked(h) && h.page_number)
? ` · <span class="label label-default">עמ׳ ${h.page_number}</span>` : '';
const activeClass = i === initialIdx ? 'panel-primary' : 'panel-default';
return `
<div class="kb-hit panel ${activeClass}" data-hit-idx="${i}"
style="margin-bottom:8px;cursor:pointer;">
<div class="panel-heading" style="direction:rtl;text-align:right;padding:6px 10px;">
<strong>[${kind}${ident}]</strong> ${this.escape(h.title || '')}
${path ? ' — <span class="text-muted">' + this.escape(path) + '</span>' : ''}
<span class="text-muted small">${pub}</span>${pageLabel}
</div>
<div class="panel-body" style="${bodyStyle}">${this.escape(h.content || '')}</div>
</div>`;
}).join('');
$results.html(this._buildSplitShell({
primaryHtml: '<div class="kb-search-list" style="height:100%;overflow-y:auto;padding-right:6px;">' + listHtml + '</div>',
referenceHtml: '<div class="kb-preview-slot" style="height:100%;"></div>',
}));
this._wireSplitter($results.find('.kb-split'));
// Cache hits on the view so click handlers can reach them.
this._searchHits = hits;
this.showSearchPreview(initialIdx);
this.$el.find('.kb-hit').off('click').on('click', function () {
const idx = parseInt($(this).data('hit-idx'), 10);
if (Number.isNaN(idx)) return;
self.$el.find('.kb-hit')
.removeClass('panel-primary').addClass('panel-default');
$(this).removeClass('panel-default').addClass('panel-primary');
self.showSearchPreview(idx);
if (_lastSearch && _lastSearch.hits === self._searchHits) {
_lastSearch.selectedIdx = idx;
_saveJson(sessionStorage, SS_SEARCH, _lastSearch);
}
});
},
// Returns the HTML for a flex-row split layout with a draggable
// 6px handle. In our Hebrew/RTL CRM users expect primary content
// (Shira's answer + sources, or the search hit list) on the visual
// RIGHT — matching how Hebrew reads — and the supporting reference
// (PDF iframe or section context) on the visual LEFT. We force
// direction:ltr on the container so the splitter's mouseX math
// doesn't get inverted by RTL inheritance; inner panes carry their
// own RTL direction as needed.
//
// Children render in DOM order [reference, handle, primary], which
// under direction:ltr maps to visual [LEFT, divider, RIGHT].
// pct is the LEFT (reference) pane's width %; default 58 matches
// the v0.1.9 col-md-7 PDF column.
_buildSplitShell: function ({primaryHtml, referenceHtml}) {
const pct = this._loadSplitPct();
return (
'<div class="kb-split" style="' +
'display:flex;flex-direction:row;direction:ltr;' +
'gap:0;height:86vh;align-items:stretch;">' +
' <div class="kb-split-left" style="' +
'flex:0 0 ' + pct + '%;min-width:200px;overflow:hidden;">' +
referenceHtml +
' </div>' +
' <div class="kb-split-handle" title="גרור כדי לשנות חלוקה" style="' +
'flex:0 0 6px;background:#dde1e7;cursor:col-resize;' +
'border-left:1px solid #cbd0d8;border-right:1px solid #cbd0d8;' +
'transition:background 0.15s;"></div>' +
' <div class="kb-split-right" style="' +
'flex:1 1 auto;min-width:200px;overflow:hidden;">' +
primaryHtml +
' </div>' +
'</div>'
);
},
_loadSplitPct: function () {
try {
const v = parseFloat(localStorage.getItem(LS_SPLIT));
if (!isNaN(v) && v >= 15 && v <= 85) return v;
} catch (e) { /* ignore */ }
// Default = 58% reference (PDF) on left, ~42% primary on right.
// Matches the visual proportions of v0.1.9's col-md-7/col-md-5
// when the CRM rendered Bootstrap rows in RTL order.
return 58;
},
_saveSplitPct: function (pct) {
try { localStorage.setItem(LS_SPLIT, String(pct)); }
catch (e) { /* ignore */ }
},
// Wires mousedown/move/up on the handle to resize the left pane.
// While dragging we cover any iframe with a transparent overlay so
// mouse events don't disappear into PDF.js. On mouseup the chosen
// ratio persists to localStorage.
_wireSplitter: function ($split) {
if (!$split.length) return;
const $handle = $split.find('.kb-split-handle');
const $left = $split.find('.kb-split-left');
const self = this;
$handle.on('mouseenter', () => $handle.css('background', '#b9c0cb'));
$handle.on('mouseleave', () => $handle.css('background', '#dde1e7'));
$handle.on('mousedown', function (e) {
e.preventDefault();
const rect = $split[0].getBoundingClientRect();
const $overlay = $('<div class="kb-drag-overlay">').css({
position: 'fixed',
inset: '0',
cursor: 'col-resize',
zIndex: 9999,
background: 'transparent',
});
$('body').append($overlay).css('user-select', 'none');
const onMove = function (ev) {
const x = ev.clientX - rect.left;
let pct = (x / rect.width) * 100;
pct = Math.max(15, Math.min(85, pct));
$left.css('flex', '0 0 ' + pct + '%');
self._dragPct = pct;
};
const onUp = function () {
$(document).off('mousemove', onMove).off('mouseup', onUp);
$overlay.remove();
$('body').css('user-select', '');
if (typeof self._dragPct === 'number') {
self._saveSplitPct(self._dragPct);
}
};
$(document).on('mousemove', onMove).on('mouseup', onUp);
});
},
showSearchPreview: function (idx) {
const hits = this._searchHits || [];
const h = hits[idx];
const $slot = this.$el.find('.kb-preview-slot');
if (!h) { $slot.empty(); return; }
const isPdf = h.kind === 'regulation' || h.kind === 'circular';
if (isPdf && h.source_id) {
const page = h.page_number || 1;
const url = '?entryPoint=KnowledgeBasePdf&sourceId='
+ encodeURIComponent(h.source_id)
+ '#page=' + encodeURIComponent(page);
$slot.html(`
<div style="display:flex;flex-direction:column;height:100%;">
<div style="direction:rtl;text-align:right;margin-bottom:6px;flex:0 0 auto;">
<span class="text-muted small">מציג עמ׳ ${page} של המסמך</span>
</div>
<iframe class="kb-pdf-iframe" src="${url}"
style="flex:1 1 auto;width:100%;border:1px solid #ddd;background:#fafafa;"
title="מקור"></iframe>
</div>
`);
return;
}
// Text source (law from Wikisource): fetch the matched chunk +
// surrounding sections so the preview pane shows context, not a
// copy of what's already in the left card. While loading, show a
// placeholder; on failure fall back to the chunk-only view.
this._renderSectionContextPlaceholder(h);
const token = this._previewToken;
const sourceId = h.source_id;
const around = (typeof h.chunk_index === 'number') ? h.chunk_index : null;
if (sourceId == null || around == null) {
this._renderSectionContextSingle(h);
return;
}
const self = this;
Espo.Ajax.getRequest('KnowledgeBase/action/chunks', {
sourceId: sourceId,
around: around,
ctx: 2,
}).then(res => {
// Bail if user has clicked a different hit while we were waiting.
if (self._previewToken !== token) return;
self._renderSectionContext(h, res && res.chunks ? res.chunks : []);
}).catch(err => {
console.warn('KB: section-window fetch failed', err);
if (self._previewToken === token) self._renderSectionContextSingle(h);
});
},
_renderSectionContextPlaceholder: function (h) {
this._previewToken = (this._previewToken || 0) + 1;
const $slot = this.$el.find('.kb-preview-slot');
const title = this.escape(h.title || '');
$slot.html(
'<div class="panel panel-default" style="direction:rtl;text-align:right;height:100%;display:flex;flex-direction:column;">' +
' <div class="panel-heading" style="flex:0 0 auto;">' + title +
' <span class="text-muted small" style="margin-right:8px;">— טוען הקשר…</span>' +
' </div>' +
' <div class="panel-body" style="flex:1 1 auto;overflow-y:auto;">' +
' <i class="fas fa-circle-notch fa-spin"></i>' +
' </div>' +
'</div>'
);
},
_renderSectionContextSingle: function (h) {
// Fallback when we can't fetch the window — render just the
// matched chunk, which is at least the full section for the
// statute chunker (1 section = 1 chunk).
this._renderSectionContext(h, [{
chunk_index: h.chunk_index,
section_ref: h.section_ref || '',
heading_path: h.heading_path || '',
content: h.content || '',
}]);
},
_renderSectionContext: function (h, chunks) {
const $slot = this.$el.find('.kb-preview-slot');
const bodyStyle =
'direction:rtl;unicode-bidi:plaintext;white-space:pre-wrap;' +
'font-family:"Segoe UI",Arial,sans-serif;line-height:1.7;text-align:right;';
const srcLink = h.source_url
? '<a href="' + this.escape(h.source_url) + '" target="_blank" rel="noopener" ' +
'class="btn btn-xs btn-default" style="margin-right:6px;">' +
'<i class="fas fa-external-link-alt"></i> פתח ב-Wikisource</a>'
: '';
// The matched chunk is highlighted; siblings render as muted
// context blocks above and below so the user can read into the
// section without leaving the CRM.
const sectionsHtml = chunks.map(c => {
const isMatch = c.chunk_index === h.chunk_index;
const ref = c.section_ref || c.heading_path || '';
const headerStyle = isMatch
? 'background:#fff3cd;border-bottom:1px solid #ffe69c;color:#664d03;font-weight:600;'
: 'background:#f5f5f7;border-bottom:1px solid #e7e7ea;color:#6c757d;';
return (
'<div style="margin-bottom:12px;border:1px solid ' +
(isMatch ? '#ffe69c' : '#e7e7ea') + ';border-radius:4px;overflow:hidden;">' +
' <div style="' + headerStyle + 'padding:6px 10px;direction:rtl;text-align:right;font-size:0.9em;">' +
(isMatch ? 'התאמה — ' : '') + this.escape(ref) +
' </div>' +
' <div style="' + bodyStyle + ';padding:10px;">' +
this.escape(c.content || '') +
' </div>' +
'</div>'
);
}).join('');
$slot.html(
'<div class="panel panel-default" style="direction:rtl;text-align:right;height:100%;display:flex;flex-direction:column;">' +
' <div class="panel-heading" style="flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;">' +
' <div><strong>' + this.escape(h.title || '') + '</strong>' +
' <span class="text-muted small" style="margin-right:6px;">— הסעיף בהקשרו</span>' +
' </div>' +
srcLink +
' </div>' +
' <div class="panel-body" style="flex:1 1 auto;overflow-y:auto;background:#fafbfc;">' +
sectionsHtml +
' </div>' +
'</div>'
);
// Scroll the matched chunk into view inside the preview pane.
const $body = $slot.find('.panel-body');
const $match = $body.find('div[style*="#fff3cd"]').closest('div[style*="border-radius:4px"]');
if ($match.length && $body.length) {
const offset = $match.position().top - 20;
$body.scrollTop(Math.max(0, offset));
}
},
renderAskAnswer: function (text, sources) {
const $results = this.$el.find('.kb-results');
sources = sources || [];
// Shira replies in markdown; render through EspoCRM's markdown
// helper when available, otherwise fall back to escaped text.
let answerHtml;
try {
const helper = this.getHelper && this.getHelper();
if (helper && typeof helper.transformMarkdownText === 'function') {
answerHtml = helper.transformMarkdownText(text || '');
if (answerHtml && answerHtml.toString) answerHtml = answerHtml.toString();
}
} catch (e) { /* fall through */ }
if (!answerHtml) {
answerHtml = '<div style="white-space:pre-wrap;">' + this.escape(text || '') + '</div>';
}
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר'};
// Dedup sources by source_id (first occurrence = most relevant),
// but remember every page that came up per source so users can
// jump directly to the right page.
const perSource = {};
sources.forEach(s => {
if (!s.source_id) return;
const key = s.source_id;
if (!perSource[key]) {
perSource[key] = {
source_id: s.source_id,
title: s.title || '',
kind: s.kind || '',
identifier: s.identifier || '',
pages: [],
};
}
if (s.page_number && !perSource[key].pages.includes(s.page_number)) {
perSource[key].pages.push(s.page_number);
}
});
const sourceList = Object.values(perSource);
if (!sourceList.length) {
// No PDFs to show — render just the answer.
$results.html(
'<div class="kb-answer panel panel-info">' +
'<div class="panel-body" style="direction:rtl;text-align:right;">' +
answerHtml + '</div></div>'
);
return;
}
// Pick the initial PDF + page: the first source (highest-ranked)
// and its first-cited page.
const first = sourceList[0];
const firstPage = first.pages[0] || 1;
// Source picker: radio-like buttons that swap the iframe's src.
const pickerItems = sourceList.map((s, i) => {
const pagesLabel = s.pages.length
? ' · עמ׳ ' + s.pages.join(', ')
: '';
const k = kindHe[s.kind] || s.kind;
const active = i === 0 ? 'btn-primary' : 'btn-default';
return `<button type="button"
class="btn btn-xs ${active} kb-pdf-pick"
data-source-id="${s.source_id}"
data-page="${s.pages[0] || 1}"
style="margin:2px;">
${this.escape(k)}${this.escape(s.title)}${pagesLabel}
</button>`;
}).join(' ');
// Extra jump links per page, for the active source.
const pageLinks = first.pages.map(p =>
`<button type="button" class="btn btn-xs btn-default kb-page-jump"
data-source-id="${first.source_id}" data-page="${p}"
style="margin:2px;">עמ׳ ${p}</button>`
).join(' ');
const initialUrl = '?entryPoint=KnowledgeBasePdf&sourceId='
+ encodeURIComponent(first.source_id)
+ '#page=' + encodeURIComponent(firstPage);
const primaryHtml = `
<div style="height:100%;overflow-y:auto;padding-right:6px;">
<div class="kb-answer panel panel-info">
<div class="panel-body" style="direction:rtl;text-align:right;">
${answerHtml}
</div>
</div>
<div class="panel panel-default" style="direction:rtl;text-align:right;">
<div class="panel-heading"><strong>📎 מקורות</strong></div>
<div class="panel-body">
<div style="margin-bottom:6px;">${pickerItems}</div>
<div class="kb-page-links" style="font-size:0.9em;">
<span class="text-muted">עמודים רלוונטיים:</span> ${pageLinks}
</div>
</div>
</div>
</div>
`;
const referenceHtml = `
<iframe class="kb-pdf-iframe"
src="${initialUrl}"
style="width:100%;height:100%;border:1px solid #ddd;background:#fafafa;"
title="מקור"></iframe>
`;
$results.html(this._buildSplitShell({primaryHtml, referenceHtml}));
this._wireSplitter($results.find('.kb-split'));
const self = this;
this.$el.find('.kb-pdf-pick').off('click').on('click', function () {
const id = $(this).data('source-id');
const page = $(this).data('page');
self.loadPdfInIframe(id, page);
self.$el.find('.kb-pdf-pick').removeClass('btn-primary').addClass('btn-default');
$(this).removeClass('btn-default').addClass('btn-primary');
// Refresh page links for the newly selected source.
const src = sourceList.find(x => x.source_id === id);
if (src && src.pages.length) {
const links = src.pages.map(p =>
`<button type="button" class="btn btn-xs btn-default kb-page-jump"
data-source-id="${src.source_id}" data-page="${p}"
style="margin:2px;">עמ׳ ${p}</button>`
).join(' ');
self.$el.find('.kb-page-links').html(
'<span class="text-muted">עמודים רלוונטיים:</span> ' + links
);
self.bindPageJumps();
}
});
this.bindPageJumps();
},
bindPageJumps: function () {
const self = this;
this.$el.find('.kb-page-jump').off('click').on('click', function () {
const id = $(this).data('source-id');
const page = $(this).data('page');
self.loadPdfInIframe(id, page);
});
},
loadPdfInIframe: function (sourceId, page) {
const url = '?entryPoint=KnowledgeBasePdf&sourceId='
+ encodeURIComponent(sourceId)
+ '#page=' + encodeURIComponent(page || 1);
this.$el.find('.kb-pdf-iframe').attr('src', url);
},
renderSourcesList: function () {
const $list = this.$el.find('.kb-sources');
if (!$list.length) return;
if (!this._sources || !this._sources.length) {
$list.html('<div class="text-muted">אין מקורות.</div>');
return;
}
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזרים'};
const grouped = {law: [], regulation: [], circular: []};
this._sources.forEach(s => {
if (grouped[s.kind]) grouped[s.kind].push(s);
});
const sections = Object.keys(grouped).filter(k => grouped[k].length).map(k => {
const items = grouped[k].map(s => {
const ident = s.identifier ? ' · ' + this.escape(s.identifier) : '';
const pub = s.published_at ? ' · ' + s.published_at : '';
return `<li>
<a href="#" role="button" data-action="viewSource" data-id="${s.id}">
${this.escape(s.title)}
</a>
<span class="text-muted small">${ident}${pub} · ${s.chunk_count} קטעים</span>
</li>`;
}).join('');
return `<h4>${kindHe[k]}</h4><ul>${items}</ul>`;
}).join('');
$list.html(sections);
},
openSource: function (sourceId) {
const $results = this.$el.find('.kb-results');
$results.html('<div class="text-muted">טוען מסמך…</div>');
Espo.Ajax.getRequest('KnowledgeBase/action/chunks', {sourceId: sourceId})
.then(res => {
const src = res.source || {};
const chunks = res.chunks || [];
const ident = src.identifier ? ' · ' + this.escape(src.identifier) : '';
const pub = src.published_at ? ' · פורסם ' + src.published_at : '';
const head = `
<div class="panel panel-default">
<div class="panel-heading">
<h3 style="margin:0;">${this.escape(src.title || '')}</h3>
<small class="text-muted">${ident}${pub}</small>
</div>
</div>`;
// If the source is a PDF (original_path ends in .pdf),
// render it inline via the EntryPoint proxy. Ctrl+F works
// inside the iframe via the browser's native PDF viewer,
// which also gives zoom, print, and download controls.
const originalPath = (src.original_path || '').toLowerCase();
if (originalPath.endsWith('.pdf')) {
const pdfUrl = '?entryPoint=KnowledgeBasePdf&sourceId=' + encodeURIComponent(sourceId);
const chunksCount = chunks.length;
const iframe = `
<iframe
src="${pdfUrl}"
style="width:100%;height:80vh;border:1px solid #ddd;background:#fafafa;"
title="${this.escape(src.title || '')}"></iframe>
<div class="small text-muted" style="margin-top:6px;direction:rtl;text-align:right;">
מוצג ה-PDF המקורי. החיפוש בעמוד &quot;חיפוש&quot; ו-&quot;שאל את שירה&quot; עובד על ${chunksCount} קטעי הטקסט שחולצו מהמסמך הזה.
</div>`;
$results.html(head + iframe);
return;
}
// Hebrew content with mixed LTR (numbers, Latin, ־) renders
// correctly only when we force RTL direction and tell the
// bidi algorithm to isolate each paragraph. Plain white-space:
// pre-wrap alone preserves line breaks but leaves the bidi
// resolution up to the browser, which produces jumbled
// glyphs on PDF-extracted Hebrew + Latin runs.
const contentStyle =
'direction:rtl;' +
'unicode-bidi:plaintext;' +
'white-space:pre-wrap;' +
'font-family:"Segoe UI",Arial,sans-serif;' +
'line-height:1.7;' +
'text-align:right;';
const body = chunks.map(c => {
const heading = c.heading_path || c.section_ref || '';
return `<div class="kb-chunk" style="margin-bottom:14px;padding:10px;border:1px solid #eee;border-radius:4px;">
${heading ? '<div class="text-muted" style="font-size:0.85em;margin-bottom:6px;direction:rtl;text-align:right;">' + this.escape(heading) + '</div>' : ''}
<div style="${contentStyle}">${this.escape(c.content)}</div>
</div>`;
}).join('');
$results.html(head + body);
})
.catch(err => this.showError(err));
},
setLoading: function (loading) {
const $btn = this.$el.find('[data-action="submit"]');
if (loading) {
$btn.prop('disabled', true).text('טוען…');
// Only overwrite results with "מחפש…" for search. For ask,
// startAskProgress() renders a richer progress panel.
if (this.mode !== 'ask') {
this.$el.find('.kb-results').html('<div class="text-muted">מחפש…</div>');
}
} else {
$btn.prop('disabled', false).text(this.getSubmitLabel());
}
},
getSubmitLabel: function () {
if (this.mode === 'ask') return 'שאל את שירה';
return 'חפש';
},
showError: function (err) {
const msg = (err && err.responseText) || (err && err.message) || 'Unknown error';
this.$el.find('.kb-results').html(
'<div class="alert alert-danger">' + this.escape(msg.slice(0, 400)) + '</div>'
);
},
escape: function (str) {
return String(str || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
});
});