fix(kb): RTL-correct pane order + new-result button + 30-min auto-clear
Three issues from the v0.2.0 user test:
1. Sides flipped relative to expectation
In our Hebrew/RTL CRM the user reads primary content (the answer or
the hit list) on the visual right and the supporting reference
(the PDF) on the visual left — that's how Hebrew layouts stage
"main + sidebar". v0.1.10's _buildSplitShell forced direction:ltr on
the splitter container (correct, for mouseX math) but laid the
children in the order [primary, handle, reference], which under LTR
put primary on the LEFT and PDF on the RIGHT. Fixed by renaming the
helper params to {primaryHtml, referenceHtml} and ordering the
children as [reference, handle, primary] — visual LEFT is now the
PDF, visual RIGHT is the answer/list. Default split moved from 42%
to 58% so the reference pane (now on the left) keeps the same
absolute width the PDF column had under v0.1.9's bootstrap rows.
2. No way to start a new query
afterRender replays _lastSearch / _lastAsk on every remount, which
means the prior result hangs around forever. Added a "חיפוש חדש" /
"שאלה חדשה" button next to the submit button. clearResults() cancels
any in-flight request (closes the EventSource for ask, drops
_activeSearch reference for search), removes the cached entry from
sessionStorage, empties the results pane, and refocuses the input.
3. Stale results from another session
Cached results now auto-expire after 30 minutes — _loadJson checks
completedAt against STALE_AFTER_MS and silently drops anything
older. So coming back tomorrow the KB tab opens clean instead of
showing yesterday's answer to whatever you asked then.
Refs Task Master #4, #6
This commit is contained in:
@@ -28,6 +28,8 @@
|
||||
<option value="circular">חוזרים</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-primary" data-action="submit">חפש</button>
|
||||
<button type="button" class="btn btn-default" data-action="clearResults"
|
||||
title="התחל חיפוש חדש (מנקה את התוצאות הקודמות)">חיפוש חדש</button>
|
||||
</div>
|
||||
<div class="small text-muted" style="margin-top:6px;">
|
||||
חיפוש Hybrid: וקטורי + מילולי + rerank. מחזיר עד 8 קטעים רלוונטיים.
|
||||
@@ -42,6 +44,8 @@
|
||||
placeholder="שאלה בשפה חופשית…"
|
||||
style="flex:1 1 320px;min-width:280px;" />
|
||||
<button type="button" class="btn btn-primary" data-action="submit">שאל את שירה</button>
|
||||
<button type="button" class="btn btn-default" data-action="clearResults"
|
||||
title="התחל שאלה חדשה (מנקה את התשובה הקודמת)">שאלה חדשה</button>
|
||||
</div>
|
||||
<div class="small text-muted" style="margin-top:6px;">
|
||||
שירה תחפש בבסיס הידע ותחזיר תשובה מסוכמת עם ציטוטים.
|
||||
|
||||
@@ -14,12 +14,21 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
const SS_ASK = 'kb-last-ask';
|
||||
const SS_SEARCH = 'kb-last-search';
|
||||
const LS_SPLIT = 'kb-split-pct'; // long-lived UI preference (not session)
|
||||
// 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; }
|
||||
}
|
||||
@@ -78,6 +87,42 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this.openSource(id);
|
||||
},
|
||||
'click [data-action="clearResults"]': function (e) {
|
||||
e.preventDefault();
|
||||
this.clearResults();
|
||||
},
|
||||
},
|
||||
|
||||
// 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 () {
|
||||
@@ -419,8 +464,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
}).join('');
|
||||
|
||||
$results.html(this._buildSplitShell({
|
||||
leftHtml: `<div class="kb-search-list" style="height:100%;overflow-y:auto;padding-left:6px;">${listHtml}</div>`,
|
||||
rightHtml: '<div class="kb-preview-slot" style="height:100%;"></div>',
|
||||
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'));
|
||||
|
||||
@@ -443,10 +488,19 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
},
|
||||
|
||||
// Returns the HTML for a flex-row split layout with a draggable
|
||||
// 6px handle. The container's flex direction is forced LTR so the
|
||||
// splitter math (clientX) doesn't get inverted by RTL — content
|
||||
// inside each pane keeps its own direction.
|
||||
_buildSplitShell: function ({leftHtml, rightHtml}) {
|
||||
// 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="' +
|
||||
@@ -454,7 +508,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
'gap:0;height:86vh;align-items:stretch;">' +
|
||||
' <div class="kb-split-left" style="' +
|
||||
'flex:0 0 ' + pct + '%;min-width:200px;overflow:hidden;">' +
|
||||
leftHtml +
|
||||
referenceHtml +
|
||||
' </div>' +
|
||||
' <div class="kb-split-handle" title="גרור כדי לשנות חלוקה" style="' +
|
||||
'flex:0 0 6px;background:#dde1e7;cursor:col-resize;' +
|
||||
@@ -462,7 +516,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
'transition:background 0.15s;"></div>' +
|
||||
' <div class="kb-split-right" style="' +
|
||||
'flex:1 1 auto;min-width:200px;overflow:hidden;">' +
|
||||
rightHtml +
|
||||
primaryHtml +
|
||||
' </div>' +
|
||||
'</div>'
|
||||
);
|
||||
@@ -473,7 +527,10 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
const v = parseFloat(localStorage.getItem(LS_SPLIT));
|
||||
if (!isNaN(v) && v >= 15 && v <= 85) return v;
|
||||
} catch (e) { /* ignore */ }
|
||||
return 42;
|
||||
// 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) {
|
||||
@@ -741,8 +798,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
+ encodeURIComponent(first.source_id)
|
||||
+ '#page=' + encodeURIComponent(firstPage);
|
||||
|
||||
const leftHtml = `
|
||||
<div style="height:100%;overflow-y:auto;padding-left:6px;">
|
||||
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}
|
||||
@@ -759,13 +816,13 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const rightHtml = `
|
||||
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({leftHtml, rightHtml}));
|
||||
$results.html(this._buildSplitShell({primaryHtml, referenceHtml}));
|
||||
this._wireSplitter($results.find('.kb-split'));
|
||||
|
||||
const self = this;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "KnowledgeBase",
|
||||
"module": "KnowledgeBase",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user