ba75463661
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>
1149 lines
54 KiB
JavaScript
1149 lines
54 KiB
JavaScript
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 המקורי. החיפוש בעמוד "חיפוש" ו-"שאל את שירה" עובד על ${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, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
},
|
||
|
||
});
|
||
|
||
});
|