feat(kb): split-view PDF jump-to-page for both ask and search modes
Search and ask modes now render results as a two-column layout: a left column with ranked hit cards (search) or Shira's answer + source picker (ask), and a right column with an iframe showing the source PDF scrolled to page_number. Clicking a hit (search) or source pill (ask) swaps the iframe's src, so users can verify a quote against the original PDF without leaving the KB tab. - search: renderSearchResults lays out results as panel cards on the left (with kind + title + section + "עמ׳ N" label); the top hit is pre-selected and its PDF loads on the right. Clicking any card re-highlights it and swaps the preview. Law-kind hits (Wikisource text) gracefully fall back to a chunk-text panel with a Wikisource link so the right pane never 404s on a text source. - ask: renderAskAnswer dedups /kb/ask's sources[] by source_id, collects all cited pages per source, and renders a picker row plus per-source page-jump buttons. First source's first page loads on initial render; buttons swap the iframe without re-running the query. Depends on shira-hermes commits e534709 + 1ca1cc0 (chunker page_number propagation + null-byte strip) — without them, every PDF collapses to page 1 in the DB and the jump links are cosmetic. Refs Task Master #1
This commit is contained in:
@@ -113,7 +113,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
}, {timeout: 240000}).then(res => {
|
||||
this.stopAskProgress();
|
||||
this.setLoading(false);
|
||||
this.renderAskAnswer(res.text || '');
|
||||
this.renderAskAnswer(res.text || '', res.sources || []);
|
||||
}).catch(err => {
|
||||
this.stopAskProgress();
|
||||
this.setLoading(false);
|
||||
@@ -172,50 +172,252 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
return;
|
||||
}
|
||||
const kindHe = {law: 'חוק', regulation: 'תקנה', circular: 'חוזר'};
|
||||
const rows = hits.map(h => {
|
||||
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 : '';
|
||||
// Content is rendered inside a white-space:pre-wrap block —
|
||||
// no need to convert \n to <br>; that would double-break.
|
||||
const content = this.escape(h.content || '');
|
||||
const srcLink = h.source_url
|
||||
? `<a href="${this.escape(h.source_url)}" target="_blank" rel="noopener">מקור</a>`
|
||||
: '';
|
||||
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 pageLabel = (isPdfBacked(h) && h.page_number)
|
||||
? ` · <span class="label label-default">עמ׳ ${h.page_number}</span>` : '';
|
||||
const activeClass = i === 0 ? 'panel-primary' : 'panel-default';
|
||||
return `
|
||||
<div class="kb-hit panel panel-default" style="margin-bottom:10px;">
|
||||
<div class="panel-heading" style="direction:rtl;text-align:right;">
|
||||
<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>
|
||||
<span class="text-muted small">${pub}</span>${pageLabel}
|
||||
</div>
|
||||
<div class="panel-body" style="${bodyStyle}">${content}</div>
|
||||
${srcLink ? '<div class="panel-footer small">' + srcLink + '</div>' : ''}
|
||||
<div class="panel-body" style="${bodyStyle}">${this.escape(h.content || '')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
$results.html(rows);
|
||||
|
||||
$results.html(`
|
||||
<div class="row" style="margin:0;">
|
||||
<div class="col-md-5" style="padding-right:0;max-height:86vh;overflow-y:auto;">
|
||||
${listHtml}
|
||||
</div>
|
||||
<div class="col-md-7 kb-search-preview" style="padding-left:0;">
|
||||
<div class="kb-preview-slot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Cache hits on the view so click handlers can reach them.
|
||||
this._searchHits = hits;
|
||||
this.showSearchPreview(0);
|
||||
|
||||
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);
|
||||
});
|
||||
},
|
||||
|
||||
renderAskAnswer: function (text) {
|
||||
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="direction:rtl;text-align:right;margin-bottom:6px;">
|
||||
<span class="text-muted small">מציג עמ׳ ${page} של המסמך</span>
|
||||
</div>
|
||||
<iframe class="kb-pdf-iframe"
|
||||
src="${url}"
|
||||
style="width:100%;height:82vh;border:1px solid #ddd;background:#fafafa;"
|
||||
title="מקור"></iframe>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text source (law from Wikisource) — show the full chunk content
|
||||
// and a link to the public source page.
|
||||
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">מקור ב-Wikisource</a>`
|
||||
: '';
|
||||
$slot.html(`
|
||||
<div class="panel panel-default" style="direction:rtl;text-align:right;">
|
||||
<div class="panel-heading">${this.escape(h.title || '')}</div>
|
||||
<div class="panel-body" style="${bodyStyle};max-height:80vh;overflow-y:auto;">
|
||||
${this.escape(h.content || '')}
|
||||
</div>
|
||||
${srcLink ? '<div class="panel-footer small">' + srcLink + '</div>' : ''}
|
||||
</div>
|
||||
`);
|
||||
},
|
||||
|
||||
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 html;
|
||||
let answerHtml;
|
||||
try {
|
||||
const helper = this.getHelper && this.getHelper();
|
||||
if (helper && typeof helper.transformMarkdownText === 'function') {
|
||||
html = helper.transformMarkdownText(text || '');
|
||||
if (html && html.toString) html = html.toString();
|
||||
answerHtml = helper.transformMarkdownText(text || '');
|
||||
if (answerHtml && answerHtml.toString) answerHtml = answerHtml.toString();
|
||||
}
|
||||
} catch (e) { /* fall through */ }
|
||||
if (!html) {
|
||||
html = '<div style="white-space:pre-wrap;">' + this.escape(text || '') + '</div>';
|
||||
if (!answerHtml) {
|
||||
answerHtml = '<div style="white-space:pre-wrap;">' + this.escape(text || '') + '</div>';
|
||||
}
|
||||
$results.html('<div class="kb-answer panel panel-info"><div class="panel-body">' + html + '</div></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);
|
||||
|
||||
$results.html(`
|
||||
<div class="row" style="margin:0;">
|
||||
<div class="col-md-6" style="padding-right:0;">
|
||||
<div class="kb-answer panel panel-info">
|
||||
<div class="panel-body" style="direction:rtl;text-align:right;max-height:80vh;overflow-y:auto;">
|
||||
${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>
|
||||
<div class="col-md-6" style="padding-left:0;">
|
||||
<iframe class="kb-pdf-iframe"
|
||||
src="${initialUrl}"
|
||||
style="width:100%;height:86vh;border:1px solid #ddd;background:#fafafa;"
|
||||
title="מקור"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
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 () {
|
||||
@@ -310,7 +512,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
setLoading: function (loading) {
|
||||
const $btn = this.$el.find('[data-action="submit"]');
|
||||
if (loading) {
|
||||
$btn.prop('disabled', true).text(this.translate('Loading', 'labels'));
|
||||
$btn.prop('disabled', true).text('טוען…');
|
||||
// Only overwrite results with "מחפש…" for search. For ask,
|
||||
// startAskProgress() renders a richer progress panel.
|
||||
if (this.mode !== 'ask') {
|
||||
|
||||
Reference in New Issue
Block a user