feat: Phase 2 — document upload from KB UI

Adds a "ניהול" tab with multipart file upload, async job tracking, and
a recent-jobs list with live status polling. Browser POSTs to
/KnowledgeBase/action/upload (PHP proxy), which forwards as multipart
to shira-hermes /admin/kb/upload. shira-hermes returns a job_id
immediately and processes parse/chunk/embed in a background task; the
browser polls every 2s until status hits done|failed.

New EspoCRM endpoints:
  POST /KnowledgeBase/action/upload   (multipart, $_FILES['file'])
  GET  /KnowledgeBase/action/jobs     (list, filtered by topicId/status)
  GET  /KnowledgeBase/action/job?id   (single-job detail)

The X-User-Name header is forwarded so kb_ingest_job records who
uploaded each file. Cross-topic guard in switchTopic stops polling
when the user changes topics mid-upload (the job continues server-side).

Depends on shira-hermes commit 0cb89fb (admin upload endpoints +
migration 002).

Refs Task Master #13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:46:42 +00:00
parent dacb7f6256
commit 6781ac4e37
7 changed files with 526 additions and 7 deletions
@@ -20,6 +20,9 @@
<li class="{{#ifEqual mode 'browse'}}active{{/ifEqual}}">
<a href="#" role="button" data-action="switchMode" data-mode="browse">עיון</a>
</li>
<li class="{{#ifEqual mode 'manage'}}active{{/ifEqual}}">
<a href="#" role="button" data-action="switchMode" data-mode="manage">ניהול</a>
</li>
</ul>
{{#ifEqual mode 'search'}}
@@ -66,5 +69,73 @@
</div>
{{/ifEqual}}
{{#ifEqual mode 'manage'}}
<div class="kb-manage" style="display:flex;flex-direction:column;gap:16px;">
<div class="panel panel-default" style="padding:12px;">
<h4 style="margin-top:0;">העלאת מסמך חדש</h4>
<form class="kb-upload-form" enctype="multipart/form-data"
style="display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label for="kb-upload-file" class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">קובץ:</label>
<input type="file" id="kb-upload-file" name="file"
accept=".pdf,.docx,.txt"
style="flex:1 1 280px;" />
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label for="kb-upload-kind" class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">סוג:</label>
<select id="kb-upload-kind" class="form-control" name="kind"
style="flex:0 0 auto;width:200px;">
<option value="law">חוק</option>
<option value="regulation">תקנות</option>
<option value="circular">חוזר</option>
</select>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label for="kb-upload-title" class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">כותרת:</label>
<input type="text" id="kb-upload-title" class="form-control" name="title"
placeholder="ברירת מחדל: שם הקובץ"
style="flex:1 1 280px;" />
</div>
<details>
<summary class="text-muted" style="cursor:pointer;">מטא-דאטה נוסף (אופציונלי)</summary>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:8px;padding-right:12px;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">מזהה:</label>
<input type="text" class="form-control" name="identifier"
placeholder="למשל: ח'(353) 14.1.2018"
style="flex:1 1 280px;" />
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">פורסם ב:</label>
<input type="date" class="form-control" name="published_at"
style="flex:0 0 auto;width:200px;" />
<label class="text-muted" style="margin:0;font-weight:normal;">תוקף מ:</label>
<input type="date" class="form-control" name="effective_at"
style="flex:0 0 auto;width:200px;" />
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">קישור מקור:</label>
<input type="url" class="form-control" name="source_url"
placeholder="https://…"
style="flex:1 1 280px;" />
</div>
</div>
</details>
<div style="display:flex;gap:8px;align-items:center;">
<button type="button" class="btn btn-primary" data-action="submitUpload">העלה</button>
<span class="kb-upload-hint text-muted small">מקסימום 50MB. PDF / DOCX / TXT.</span>
</div>
</form>
</div>
<div class="panel panel-default" style="padding:12px;">
<h4 style="margin-top:0;">משימות אחרונות</h4>
<div class="kb-jobs-list">
<div class="text-muted">טוען…</div>
</div>
</div>
</div>
{{/ifEqual}}
<div class="kb-results" style="margin-top:12px;"></div>
</div>
@@ -12,6 +12,9 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
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
let _activeJobId = null; // currently-polled ingest job id (across remounts)
let _activeJobTopicId = null; // topic this job belongs to (for cross-topic guard)
let _pollIntervalId = null; // setInterval handle so remounts don't double-poll
const SS_ASK = 'kb-last-ask';
const SS_SEARCH = 'kb-last-search';
@@ -117,6 +120,10 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
if (!Number.isFinite(id) || id === this.topicId) return;
this.switchTopic(id);
},
'click [data-action="submitUpload"]': function (e) {
e.preventDefault();
this.runUpload();
},
},
// Cancel any in-flight ask/search, drop the cached last-result for
@@ -190,6 +197,214 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
});
},
// Phase 2: lazy-loads the recent-jobs list and resumes polling of any
// in-flight upload job. No-op if .kb-jobs-list isn't in the DOM (i.e.
// the user isn't currently on the 'manage' tab).
_ensureManagePanelLoaded: function () {
const self = this;
const $list = this.$el.find('.kb-jobs-list');
if (!$list.length || this.topicId == null) return;
// Resume polling if we navigated away mid-upload and came back.
if (_activeJobId && _activeJobTopicId === this.topicId) {
this._startJobPolling(_activeJobId);
} else if (_activeJobId && _activeJobTopicId !== this.topicId) {
// Cross-topic guard: an upload in another topic is invisible
// here. The polling continues silently in the background.
}
// Always refresh the recent-jobs list on mount.
this._loadJobsList();
},
_loadJobsList: function () {
const self = this;
const $list = this.$el.find('.kb-jobs-list');
if (!$list.length) return;
const topicAtFetch = this.topicId;
Espo.Ajax.getRequest('KnowledgeBase/action/jobs', {
topicId: topicAtFetch,
limit: 20,
}).then(res => {
if (self.topicId !== topicAtFetch) return; // user switched
self._renderJobsList(res && res.items || []);
}).catch(err => {
console.error('KB: failed to load jobs', err);
$list.html('<div class="text-muted">שגיאה בטעינת רשימת המשימות.</div>');
});
},
_renderJobsList: function (items) {
const $list = this.$el.find('.kb-jobs-list');
if (!$list.length) return;
if (!items.length) {
$list.html('<div class="text-muted">אין משימות עדיין.</div>');
return;
}
const statusHe = {
queued: '<span class="label label-default">בתור</span>',
processing: '<span class="label label-info">מעובד…</span>',
done: '<span class="label label-success">הושלם</span>',
failed: '<span class="label label-danger">נכשל</span>',
};
const kindHe = {law: 'חוק', regulation: 'תקנות', circular: 'חוזר'};
const rows = items.map(j => {
const when = j.completed_at || j.started_at || j.created_at || '';
const whenShort = String(when).slice(0, 19).replace('T', ' ');
const meta = j.metadata_json || {};
const title = this.escape(meta.title || j.original_filename || '—');
const fname = this.escape(j.original_filename || '');
const detail = j.status === 'done'
? `<span class="text-muted">${j.chunks_created || 0} קטעים</span>`
: (j.status === 'failed'
? `<span class="text-danger" title="${this.escape(j.error_message || '')}">${this.escape((j.error_message || '').slice(0, 80))}</span>`
: '');
return `<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #eee;padding:8px 0;gap:12px;">
<div style="flex:1 1 auto;min-width:0;">
<div style="font-weight:500;">${title}</div>
<div class="text-muted small">
${kindHe[j.kind] || j.kind} · ${this.escape(whenShort)}${j.requested_by_user ? ' · ' + this.escape(j.requested_by_user) : ''}
</div>
${detail ? '<div class="small">' + detail + '</div>' : ''}
</div>
<div>${statusHe[j.status] || this.escape(j.status)}</div>
</div>`;
}).join('');
$list.html(rows);
},
// Live status panel rendered above the upload form during processing.
_renderJobStatus: function (job) {
const $form = this.$el.find('.kb-upload-form');
if (!$form.length) return;
// Remove any previous banner so we can replace it.
this.$el.find('.kb-upload-status').remove();
const statusHe = {
queued: 'בתור',
processing: 'מעובד…',
done: 'הושלם בהצלחה',
failed: 'נכשל',
};
const klass = job.status === 'done' ? 'alert-success'
: job.status === 'failed' ? 'alert-danger'
: 'alert-info';
const filename = this.escape(job.original_filename || '');
const extra = job.status === 'done'
? ` · נוצרו ${job.chunks_created || 0} קטעים`
: (job.status === 'failed'
? ` · ${this.escape((job.error_message || '').slice(0, 200))}`
: '');
const html = `<div class="kb-upload-status alert ${klass}" style="margin-bottom:12px;">
<strong>${this.escape(filename)}</strong> — ${statusHe[job.status] || this.escape(job.status)}${extra}
</div>`;
$form.before(html);
},
_startJobPolling: function (jobId) {
const self = this;
this._stopJobPolling();
_activeJobId = jobId;
_activeJobTopicId = this.topicId;
const tick = () => {
Espo.Ajax.getRequest('KnowledgeBase/action/job', {id: jobId})
.then(job => {
if (!self.$el || !self.$el.length) return;
self._renderJobStatus(job);
if (job.status === 'done' || job.status === 'failed') {
self._stopJobPolling();
_activeJobId = null;
_activeJobTopicId = null;
// Refresh the recent-jobs list to surface the new row.
self._loadJobsList();
}
})
.catch(err => {
console.error('KB: job poll failed', err);
// Don't kill the interval on a single transient error;
// let the next tick try again.
});
};
tick(); // immediate first read so the user sees status fast
_pollIntervalId = setInterval(tick, 2000);
},
_stopJobPolling: function () {
if (_pollIntervalId) {
clearInterval(_pollIntervalId);
_pollIntervalId = null;
}
},
runUpload: function () {
const $form = this.$el.find('.kb-upload-form');
const $file = $form.find('input[type="file"]');
const file = ($file[0] && $file[0].files && $file[0].files[0]) || null;
if (!file) {
alert('יש לבחור קובץ.');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('הקובץ גדול מ-50MB. אנא העלה קובץ קטן יותר.');
return;
}
if (this.topicId == null) {
alert('נושא לא נבחר.');
return;
}
const fd = new FormData();
fd.append('file', file, file.name);
fd.append('kind', $form.find('select[name="kind"]').val() || 'circular');
fd.append('topicId', String(this.topicId));
['title', 'identifier', 'published_at', 'effective_at', 'source_url'].forEach(name => {
const v = ($form.find(`[name="${name}"]`).val() || '').trim();
if (v) fd.append(name, v);
});
const $btn = this.$el.find('[data-action="submitUpload"]');
$btn.prop('disabled', true).text('מעלה…');
const self = this;
// Bypass Espo.Ajax.postRequest (which JSON-encodes the body) — use
// raw $.ajax so the FormData multipart body is sent intact.
$.ajax({
url: 'api/v1/KnowledgeBase/action/upload',
method: 'POST',
data: fd,
contentType: false,
processData: false,
cache: false,
}).then(res => {
$btn.prop('disabled', false).text('העלה');
// Reset the form so the user can upload another.
$form[0].reset();
if (res && res.job_id) {
self._renderJobStatus({
original_filename: file.name,
status: res.status || 'queued',
});
self._startJobPolling(res.job_id);
}
}).fail(xhr => {
$btn.prop('disabled', false).text('העלה');
let msg = 'שגיאה לא ידועה';
try {
const json = JSON.parse(xhr.responseText || '{}');
msg = json.message || json.detail || xhr.responseText || msg;
} catch (e) {
msg = xhr.responseText || msg;
}
self.$el.find('.kb-upload-status').remove();
self.$el.find('.kb-upload-form').before(
'<div class="kb-upload-status alert alert-danger" style="margin-bottom:12px;">'
+ 'העלאה נכשלה: ' + self.escape(msg.slice(0, 300))
+ '</div>'
);
});
},
_ensureTopicsLoaded: function () {
if (_topics) return Promise.resolve(_topics);
if (_topicsPromise) return _topicsPromise;
@@ -253,6 +468,13 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
_clear(sessionStorage, SS_SEARCH);
this._sources = null;
this._sourcesPromise = null;
// An in-flight upload belongs to the topic where it started;
// stop polling it here so the new topic doesn't see a stale banner.
// The job continues processing on the server; the user can find
// it under "משימות אחרונות" if they switch back.
this._stopJobPolling();
_activeJobId = null;
_activeJobTopicId = null;
this.stopAskProgress();
this.setLoading(false);
this.$el.find('.kb-results').empty();
@@ -272,6 +494,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
if (!self.$el || !self.$el.length) return;
self._populateTopicPicker();
self._ensureSourcesLoaded();
self._ensureManagePanelLoaded();
};
if (_topics) {
afterTopics();