feat(kb): ask survives view switch + search query expansion (server-side)
Two recurring frustrations from the v0.1.8 demo:
1. Searching "מהי תקנה 37" only returned the תקנה 37 חוזר and the law —
ספר הליקויים, the canonical medical reference, was crowded out by the
document whose title literally contained the query terms. Fixed in
shira-hermes 1441a41 by adding an LLM-driven query-expansion path to
/kb/search (default expand=true). Same query now also returns 2 hits
from src 29 (ספר הליקויים).
2. Asking Shira a question and clicking another EspoCRM view dropped the
in-flight request — coming back rendered a blank kb-results pane, even
though the request had completed in the background. Fixed here:
{question, promise, startedAt} are now stored at module scope (closure
shared across remounts of this view). afterRender re-binds handlers
for any active ask, resumes the progress panel with the original start
time so the elapsed counter doesn't reset, and replays the most recent
completed answer from sessionStorage so the user sees their result
even after a hard reload. State is intentionally session-scoped — no
answers persist across browser sessions.
Refs Task Master #3, #4
This commit is contained in:
@@ -1,5 +1,34 @@
|
||||
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, promise} while a request is in flight
|
||||
let _lastAsk = null; // {question, text, sources, completedAt} after success
|
||||
|
||||
const SS_KEY = 'kb-last-ask';
|
||||
|
||||
function _loadLastFromSession() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.question === 'string') return parsed;
|
||||
} catch (e) { /* corrupted, ignore */ }
|
||||
return null;
|
||||
}
|
||||
function _saveLastToSession(entry) {
|
||||
try { sessionStorage.setItem(SS_KEY, JSON.stringify(entry)); }
|
||||
catch (e) { /* quota or disabled, fine */ }
|
||||
}
|
||||
function _clearLastInSession() {
|
||||
try { sessionStorage.removeItem(SS_KEY); } catch (e) { /* ignore */ }
|
||||
}
|
||||
if (!_lastAsk) _lastAsk = _loadLastFromSession();
|
||||
|
||||
return Dep.extend({
|
||||
|
||||
template: 'knowledge-base:kb/index',
|
||||
@@ -68,6 +97,25 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
} else if (this._sources) {
|
||||
this.renderSourcesList();
|
||||
}
|
||||
|
||||
// Recover ask state across view remounts (user navigated away and
|
||||
// back). _activeAsk wins — there's still a request in flight, so
|
||||
// re-show the progress panel and re-bind handlers. Otherwise
|
||||
// _lastAsk replays the most recent answer in this browser session.
|
||||
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);
|
||||
this.startAskProgress(_activeAsk.startedAt);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -102,30 +150,65 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
},
|
||||
|
||||
runAsk: function (message) {
|
||||
// A new ask invalidates whatever last-result was on screen.
|
||||
_lastAsk = null;
|
||||
_clearLastInSession();
|
||||
|
||||
this.setLoading(true);
|
||||
this.startAskProgress();
|
||||
// Ask goes through the full agent loop (search + rerank + LLM),
|
||||
// which routinely takes 30-90s. The default jQuery Ajax timeout
|
||||
// is too short; override it here so the browser doesn't give up
|
||||
// before shira-hermes responds.
|
||||
Espo.Ajax.postRequest('KnowledgeBase/action/ask', {
|
||||
const promise = Espo.Ajax.postRequest('KnowledgeBase/action/ask', {
|
||||
message: message,
|
||||
}, {timeout: 240000}).then(res => {
|
||||
this.stopAskProgress();
|
||||
this.setLoading(false);
|
||||
this.renderAskAnswer(res.text || '', res.sources || []);
|
||||
}, {timeout: 240000});
|
||||
_activeAsk = {question: message, promise: promise, startedAt: Date.now()};
|
||||
this.startAskProgress(_activeAsk.startedAt);
|
||||
this._attachAskHandlers(_activeAsk);
|
||||
},
|
||||
|
||||
// Wire .then/.catch on a (possibly module-level) ask promise to
|
||||
// whichever view is currently mounted. Called from runAsk for the
|
||||
// initial bind and from afterRender when the user navigates back to
|
||||
// the KB view while an earlier request is still in flight.
|
||||
_attachAskHandlers: function (ask) {
|
||||
const self = this;
|
||||
ask.promise.then(res => {
|
||||
const entry = {
|
||||
question: ask.question,
|
||||
text: res.text || '',
|
||||
sources: res.sources || [],
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
_lastAsk = entry;
|
||||
_saveLastToSession(entry);
|
||||
if (_activeAsk === ask) _activeAsk = null;
|
||||
if (self.$el && self.$el.length) {
|
||||
self.stopAskProgress();
|
||||
self.setLoading(false);
|
||||
if (self.mode === 'ask') {
|
||||
self.renderAskAnswer(entry.text, entry.sources);
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
this.stopAskProgress();
|
||||
this.setLoading(false);
|
||||
this.showError(err);
|
||||
if (_activeAsk === ask) _activeAsk = null;
|
||||
if (self.$el && self.$el.length) {
|
||||
self.stopAskProgress();
|
||||
self.setLoading(false);
|
||||
self.showError(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
startAskProgress: function () {
|
||||
startAskProgress: function (startedAt) {
|
||||
// Show a live elapsed-time indicator so users know the request
|
||||
// is still running rather than stuck. Typical answers arrive in
|
||||
// 30-90s; we warn after 2 minutes.
|
||||
// 30-90s; we warn after 2 minutes. `startedAt` is passed in so
|
||||
// a request that began before a view re-mount keeps showing the
|
||||
// accumulated elapsed time, not 0.
|
||||
if (this.mode !== 'ask') return;
|
||||
this.stopAskProgress();
|
||||
startedAt = startedAt || Date.now();
|
||||
const $results = this.$el.find('.kb-results');
|
||||
$results.html(
|
||||
'<div class="kb-ask-progress panel panel-info">' +
|
||||
@@ -144,8 +227,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
' </div>' +
|
||||
'</div>'
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
this._askTimer = setInterval(() => {
|
||||
const tick = () => {
|
||||
const sec = Math.round((Date.now() - startedAt) / 1000);
|
||||
const $elapsed = this.$el.find('.kb-elapsed');
|
||||
if ($elapsed.length) $elapsed.text(sec);
|
||||
@@ -155,7 +237,9 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
'לוקח יותר זמן מהרגיל. עדיין ממתינים…</div>'
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
tick();
|
||||
this._askTimer = setInterval(tick, 1000);
|
||||
},
|
||||
|
||||
stopAskProgress: function () {
|
||||
|
||||
Reference in New Issue
Block a user