Compare commits

...

31 Commits

Author SHA1 Message Date
chaim 87bc4b6770 feat: SmartAssistant v2.3.0 — document tools + NetworkStorage integration
- Add upload_document, generate_document, merge_documents tools
- Rewrite DocumentAnalyzer to use NetworkStorageIntegration instead of custom WebDAV client
- Delete FileStorage classes (FileStorageInterface, FileStorageFactory, NextCloudFileStorage, WebDavFileStorage)
- Use networkStorageFolderPath instead of nextCloudFolderPath

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:53:27 +00:00
chaim 2fe807b7f9 feat: SmartAssistant v2.2.0 — EspoCRM sends its own credentials to AI Gateway
- EspoCRM sends url + apiKey in every request to the gateway
- New Integration field: espocrmApiKey
- Gateway no longer needs ESPOCRM_URL/ESPOCRM_API_KEY env vars

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 06:46:13 +00:00
chaim 026c32a3f7 chore: bump version to 2.1.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:17:07 +00:00
chaim 2e350e33b9 fix: avatar tight face crop — no gray corners
Inset crop to avoid icon's rounded corners, pure blue background fills entire square.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:16:24 +00:00
chaim 1d7eb19a43 fix: avatar fills full circle + badge outside overflow
- Square avatar image (no circle mask) — CSS border-radius handles clipping
- Badge moved outside .sa-fab to separate wrapper, not clipped by overflow:hidden
- object-fit: cover ensures image fills entire circle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:06:23 +00:00
chaim 0fc53b54b7 chore: bump version to 2.1.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:48:57 +00:00
chaim 4c0bf3a67c fix: circular avatar + visible notification badge
- Avatar image pre-cropped as circle with transparency
- Removed overflow:hidden from FAB so badge is not clipped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:32:13 +00:00
chaim 35c57d36ae feat: SmartAssistant v2.1.0 — full permissions, multi-line input, BiDi tables, Shira avatar
- Shira executes all actions inline (create task, meeting, note) without approval buttons
- Actions work from both case and office mode (auto-resolves case from drill-down)
- Tasks/meetings can be created without a case parent
- Chat input is now multi-line textarea with auto-grow (Shift+Enter for new line)
- Markdown tables render as proper HTML tables with LRM marks for BiDi safety
- Robot icon replaced with Shira's avatar in FAB, chat header, and message bubbles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:21:29 +00:00
chaim 0f77631f75 feat: SmartAssistant v2.0.0 — feminine Hebrew + AI Gateway
- Shira speaks in feminine form (חושבת instead of חושב)
- Neutral verb forms for buttons (הוספת/חיפוש instead of הוסף/חפש)
- Updated fallback texts to reference שירה by name
- Description updated: AI Gateway integration replaces n8n

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:40:48 +00:00
chaim 81fa5f3a08 feat: auto-refresh panels after Shira executes an action
When Shira creates a task, meeting, or other entity, the current
detail view panels now refresh automatically via model.trigger('update-all').
For tasks and meetings, also refreshes the activities/tasks side panels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:53:01 +00:00
chaim cf0e9ad2bc fix: stdClass as array error in ConversationRepository
EspoCRM returns JSON data as stdClass objects, not arrays. Using
array access ($data[$i]['role']) on stdClass causes fatal error.
Fixed by using json_decode(json_encode(...), true) to ensure deep
conversion to associative arrays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:33:25 +00:00
chaim 07df19b412 fix: stream notes showing empty content - add data() method
SmartRequest and SmartAction note views were missing data() method,
so {{post}} in the template resolved to empty string. Added proper
data() with escapeString formatting, consistent with SmartResponse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:09:05 +00:00
chaim b9542e3980 fix: add missing var self = this in showDetail()
self was resolving to window.self instead of the View instance
inside the forEach callback, causing self.escapeString to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:05:30 +00:00
chaim 4f5b6be884 fix: register stream note views in clientDefs.Note.itemViews
EspoCRM resolves note type views via clientDefs.Note.itemViews metadata.
Without this mapping, it looked for SmartRequest/SmartResponse/SmartAction
in the core views path instead of the module path, causing 404 errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:40:48 +00:00
chaim b8e8c01da5 fix: use self.escapeString() for consistency with EspoCRM API
Replace jQuery text escaping with EspoCRM's built-in View.escapeString()
to maintain consistency with the rest of the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:26:19 +00:00
chaim 62004360e8 fix: replace Espo.Utils.escapeString with jQuery text escaping
Espo.Utils.escapeString does not exist in EspoCRM 8.x, causing
TypeError crash when clicking dashlet cards. Use jQuery .text().html()
for XSS-safe HTML escaping instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:53:47 +00:00
chaim f616065585 fix: dashlet detail shows actual items instead of empty alerts
Cards "תיקים פתוחים" and "דיונים השבוע" showed counts but clicking
revealed "אין פריטים להצגה" because card counts came from direct DB
queries while detail filtered from alerts with stricter criteria.

Added getOpenCaseItems() and getUpcomingHearingItems() to AlertCalculator,
included them in summary API response, and wired dashlet to use them.

Also: renamed extension to SmartAssistant, title to "שירה — עוזרת אישית".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:25:31 +00:00
chaim 1eb856b178 feat: rename assistant to שירה (Shira) 2026-03-31 23:12:31 +00:00
chaim c9d08aa1b9 feat: rename assistant to שירה (Shira) 2026-03-31 23:12:29 +00:00
chaim 233f56eccf feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:59 +00:00
chaim 0157b31249 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:33 +00:00
chaim 49c42e3d20 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:29 +00:00
chaim 4dad65f578 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:22 +00:00
chaim f116611c73 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:09:16 +00:00
chaim ff2388e7e1 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:09:10 +00:00
chaim 92c3cd2cdc release: v1.0.7 2026-03-31 23:01:33 +00:00
chaim 3102bcff77 feat: clickable dashlet cards with detail drill-down 2026-03-31 23:01:19 +00:00
chaim f5c60f4272 release: v1.0.6 2026-03-31 22:56:28 +00:00
chaim 21c3e33635 fix: add getColor to dashlet for EspoCRM v9 compatibility 2026-03-31 22:56:08 +00:00
chaim ffbb648dd5 release: v1.0.5 2026-03-31 22:53:23 +00:00
chaim f915faafdc fix: add getActionItemDataList to dashlet for EspoCRM v9 compatibility 2026-03-31 22:53:10 +00:00
23 changed files with 705 additions and 495 deletions
+1 -3
View File
@@ -1,12 +1,10 @@
# SmartAssistant - עוזר חכם # SmartAssistant - עוזר חכם
**גרסה:** 1.0.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0 **גרסה:** 1.1.7 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור ## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM. עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
מחליף את CrmAssistant + OfficeAssistant (deprecated).
## תלויות ## תלויות
- Webhook חיצוני (n8n או דומה) לעיבוד AI - Webhook חיצוני (n8n או דומה) לעיבוד AI
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים) - NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -5,38 +5,68 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
name: 'SmartAssistant', name: 'SmartAssistant',
getTitle: function () { getTitle: function () {
return this.translate('Smart Assistant', 'labels', 'SmartAssistant') || 'Smart Assistant'; return 'שירה — עוזרת אישית';
}, },
templateContent: '' + templateContent: '' +
'<div style="direction: rtl; text-align: right; padding: 8px;">' + '<div style="direction: rtl; text-align: right; padding: 8px;">' +
'<div class="sa-dashlet-cards" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;"></div>' + '<div class="sa-dashlet-cards" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;"></div>' +
'<div class="sa-dashlet-alerts" style="max-height: 200px; overflow-y: auto;"></div>' + '<div class="sa-dashlet-detail" style="max-height: 250px; overflow-y: auto;"></div>' +
'</div>', '</div>',
events: {
'click .sa-card': function (e) {
var type = $(e.currentTarget).data('type');
this.showDetail(type);
},
},
afterRender: function () { afterRender: function () {
this.loadSummary(); this._alerts = [];
this._summary = {};
this._activeType = null;
var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
if (!result.enabled) {
self.$el.find('.sa-dashlet-cards').html(
'<div style="text-align: center; color: #999; padding: 20px; font-size: 13px;">שירה לא מופעלת. הפעל ב-Admin &gt; Integrations &gt; Smart Assistant</div>'
);
return;
}
self.loadSummary();
}).catch(function () {
self.loadSummary();
});
}, },
loadSummary: function () { loadSummary: function () {
var self = this; var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) { Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) {
self.renderCards(response.summary || {}); self._summary = response.summary || {};
self.renderAlerts(response.alerts || []); self._alerts = response.alerts || [];
self._openCases = response.openCases || [];
self._upcomingHearings = response.upcomingHearings || [];
self.renderCards(self._summary);
self.showDetail(self._activeType);
}).catch(function () {}); }).catch(function () {});
}, },
renderCards: function (summary) { renderCards: function (summary) {
var cards = [ var cards = [
{label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'}, {type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
{label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'}, {type: 'overdue', label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'},
{label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'}, {type: 'hearings', label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'},
{label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'}, {type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
]; ];
var self = this;
var html = ''; var html = '';
cards.forEach(function (c) { cards.forEach(function (c) {
html += '<div style="flex: 1; min-width: 80px; background: ' + c.bg + '; padding: 10px; border-radius: 8px; text-align: center;">' + var active = self._activeType === c.type;
var border = active ? '2px solid ' + c.color : '2px solid transparent';
var cursor = c.value > 0 ? 'pointer' : 'default';
html += '<div class="sa-card" data-type="' + c.type + '" style="flex: 1; min-width: 80px; background: ' + c.bg + '; padding: 10px; border-radius: 8px; text-align: center; cursor: ' + cursor + '; border: ' + border + '; transition: border 0.2s;">' +
'<div style="font-size: 22px; font-weight: bold; color: ' + c.color + ';">' + c.value + '</div>' + '<div style="font-size: 22px; font-weight: bold; color: ' + c.color + ';">' + c.value + '</div>' +
'<div style="font-size: 11px; color: ' + c.color + ';"><span class="fas ' + c.icon + '"></span> ' + c.label + '</div>' + '<div style="font-size: 11px; color: ' + c.color + ';"><span class="fas ' + c.icon + '"></span> ' + c.label + '</div>' +
'</div>'; '</div>';
@@ -45,10 +75,36 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
this.$el.find('.sa-dashlet-cards').html(html); this.$el.find('.sa-dashlet-cards').html(html);
}, },
renderAlerts: function (alerts) { showDetail: function (type) {
var $alerts = this.$el.find('.sa-dashlet-alerts'); var self = this;
if (alerts.length === 0) { var $detail = this.$el.find('.sa-dashlet-detail');
$alerts.html('<div style="text-align: center; color: #4caf50; padding: 12px;">אין התראות</div>');
if (!type || type === this._activeType && $detail.html()) {
// Toggle off
this._activeType = null;
$detail.html('');
this.renderCards(this._summary);
return;
}
this._activeType = type;
this.renderCards(this._summary);
var alerts = this._alerts;
var items = [];
if (type === 'overdue') {
items = alerts.filter(function (a) { return a.type === 'overdue_task'; });
} else if (type === 'hearings') {
items = this._upcomingHearings || [];
} else if (type === 'attention') {
items = alerts.filter(function (a) { return a.severity === 'critical' || a.severity === 'warning'; });
} else if (type === 'open') {
items = this._openCases || [];
}
if (items.length === 0) {
$detail.html('<div style="text-align: center; color: #999; padding: 12px; font-size: 12px;">אין פריטים להצגה</div>');
return; return;
} }
@@ -56,21 +112,36 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
var severityIcons = {critical: 'fa-exclamation-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle'}; var severityIcons = {critical: 'fa-exclamation-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle'};
var html = ''; var html = '';
alerts.slice(0, 10).forEach(function (a) { items.forEach(function (a) {
var color = severityColors[a.severity] || '#333'; var color = severityColors[a.severity] || '#333';
var icon = severityIcons[a.severity] || 'fa-circle'; var icon = severityIcons[a.severity] || 'fa-circle';
var link = a.caseId ? '<a href="#Case/view/' + a.caseId + '" style="color: ' + color + '; text-decoration: none;">' : '<span>';
var linkEnd = a.caseId ? '</a>' : '</span>';
html += '<div style="padding: 6px 0; border-bottom: 1px solid #f5f5f5; font-size: 12px; color: ' + color + ';">' + html += '<div style="padding: 6px 8px; border-bottom: 1px solid #f0f0f0; font-size: 12px; color: ' + color + ';">' +
'<span class="fas ' + icon + '" style="margin-left: 6px;"></span>' + '<span class="fas ' + icon + '" style="margin-left: 6px;"></span>' +
'<span>' + Espo.Utils.escapeString(a.message) + '</span>' + link + self.escapeString(a.message) + linkEnd +
'</div>'; '</div>';
}); });
$alerts.html(html); $detail.html(html);
}, },
actionRefresh: function () { actionRefresh: function () {
this.loadSummary(); this.loadSummary();
}, },
getActionItemDataList: function () {
return [
{
name: 'refresh',
iconHtml: '<span class="fas fa-sync-alt"></span>',
},
];
},
getColor: function () {
return '#5c6bc0';
},
}); });
}); });
@@ -3,13 +3,16 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
return View.extend({ return View.extend({
templateContent: '' + templateContent: '' +
'<div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' + '<div class="sa-fab-wrapper">' +
' <span class="fas fa-robot"></span>' + ' <div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" class="sa-fab-avatar" alt="שירה">' +
' </div>' +
' <span class="sa-fab-badge" style="display:none;"></span>' + ' <span class="sa-fab-badge" style="display:none;"></span>' +
'</div>' + '</div>' +
'<div class="sa-chat-panel" style="display:none;">' + '<div class="sa-chat-panel" style="display:none;">' +
' <div class="sa-chat-header">' + ' <div class="sa-chat-header">' +
' <span class="sa-chat-title">{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}</span>' + ' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" style="width: 26px; height: 26px; border-radius: 50%; margin-left: 8px;">' +
' <span class="sa-chat-title">שירה</span>' +
' <span class="sa-mode-badge"></span>' + ' <span class="sa-mode-badge"></span>' +
' <div class="sa-chat-header-actions">' + ' <div class="sa-chat-header-actions">' +
' <button class="btn btn-link sa-memory-btn" type="button" title="{{translate \'Case Memory\' scope=\'SmartAssistant\'}}" style="display:none;">' + ' <button class="btn btn-link sa-memory-btn" type="button" title="{{translate \'Case Memory\' scope=\'SmartAssistant\'}}" style="display:none;">' +
@@ -50,7 +53,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
' <div class="sa-chat-summary" style="display:none;"></div>' + ' <div class="sa-chat-summary" style="display:none;"></div>' +
' <div class="sa-chat-messages"></div>' + ' <div class="sa-chat-messages"></div>' +
' <div class="sa-chat-input-row">' + ' <div class="sa-chat-input-row">' +
' <input type="text" class="form-control sa-chat-input" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}">' + ' <textarea class="form-control sa-chat-input" rows="1" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}"></textarea>' +
' <button class="btn btn-primary sa-chat-send" type="button" disabled>' + ' <button class="btn btn-primary sa-chat-send" type="button" disabled>' +
' <span class="fas fa-paper-plane"></span>' + ' <span class="fas fa-paper-plane"></span>' +
' </button>' + ' </button>' +
@@ -59,9 +62,11 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
'</div>' + '</div>' +
'<style>' + '<style>' +
'.smart-assistant-fab-container { position: fixed; bottom: 24px; left: 24px; z-index: 1050; direction: rtl; font-family: inherit; }' + '.smart-assistant-fab-container { position: fixed; bottom: 24px; left: 24px; z-index: 1050; direction: rtl; font-family: inherit; }' +
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: #5c6bc0; color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s, background 0.2s; font-size: 22px; position: relative; }' + '.sa-fab-wrapper { position: relative; display: inline-block; }' +
'.sa-fab:hover { background: #3f51b5; transform: scale(1.08); }' + '.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: transparent; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s; overflow: hidden; }' +
'.sa-fab-badge { position: absolute; top: -4px; right: -4px; background: #c62828; color: #fff; border-radius: 10px; min-width: 20px; height: 20px; font-size: 11px; font-weight: bold; display: flex; align-items: center; justify-content: center; padding: 0 5px; }' + '.sa-fab:hover { transform: scale(1.08); }' +
'.sa-fab-avatar { width: 100%; height: 100%; object-fit: cover; }' +
'.sa-fab-badge { position: absolute; top: -4px; right: -4px; background: #c62828; color: #fff; border-radius: 10px; min-width: 20px; height: 20px; font-size: 11px; font-weight: bold; display: flex; align-items: center; justify-content: center; padding: 0 5px; z-index: 1; }' +
'.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; }' + '.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; }' +
'@keyframes sa-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }' + '@keyframes sa-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }' +
'.sa-chat-header { display: flex; align-items: center; padding: 10px 16px; background: #5c6bc0; color: #fff; gap: 8px; }' + '.sa-chat-header { display: flex; align-items: center; padding: 10px 16px; background: #5c6bc0; color: #fff; gap: 8px; }' +
@@ -76,14 +81,20 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
'.sa-chat-messages:empty::before { content: attr(data-empty-text); display: block; text-align: center; color: #999; padding: 30px 10px; font-size: 13px; }' + '.sa-chat-messages:empty::before { content: attr(data-empty-text); display: block; text-align: center; color: #999; padding: 30px 10px; font-size: 13px; }' +
'.sa-msg { margin-bottom: 10px; padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; max-width: 90%; word-wrap: break-word; }' + '.sa-msg { margin-bottom: 10px; padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; max-width: 90%; word-wrap: break-word; }' +
'.sa-msg-user { background: #e3f2fd; margin-left: auto; margin-right: 0; text-align: right; }' + '.sa-msg-user { background: #e3f2fd; margin-left: auto; margin-right: 0; text-align: right; }' +
'.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; }' + '.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; position: relative; padding-right: 36px; }' +
'.sa-msg-assistant::before { content: ""; position: absolute; right: 6px; top: 6px; width: 24px; height: 24px; border-radius: 50%; background: url(client/custom/modules/smart-assistant/img/shira-avatar.png) center/cover; }' +
'.sa-msg-error { background: #fce4ec; color: #c62828; }' + '.sa-msg-error { background: #fce4ec; color: #c62828; }' +
'.sa-msg-loading { background: #f5f5f5; color: #999; }' + '.sa-msg-loading { background: #f5f5f5; color: #999; }' +
'.sa-table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; direction: rtl; text-align: right; }' +
'.sa-table th, .sa-table td { border: 1px solid #ddd; padding: 5px 8px; white-space: nowrap; unicode-bidi: plaintext; }' +
'.sa-table th { background: #f0eef8; font-weight: 600; color: #333; }' +
'.sa-table tr:nth-child(even) { background: #fafafa; }' +
'.sa-table tr:hover { background: #f5f0ff; }' +
'.sa-msg-action { background: #fff8e1; padding: 6px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }' + '.sa-msg-action { background: #fff8e1; padding: 6px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }' +
'.sa-action-btns { display: flex; gap: 4px; }' + '.sa-action-btns { display: flex; gap: 4px; }' +
'.sa-action-btns .btn { padding: 2px 8px; font-size: 11px; border-radius: 4px; }' + '.sa-action-btns .btn { padding: 2px 8px; font-size: 11px; border-radius: 4px; }' +
'.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; }' + '.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; align-items: flex-end; }' +
'.sa-chat-input { flex: 1; border-radius: 20px; padding: 8px 14px; font-size: 13px; direction: rtl; }' + '.sa-chat-input { flex: 1; border-radius: 16px; padding: 8px 14px; font-size: 13px; direction: rtl; resize: none; overflow-y: auto; max-height: 120px; min-height: 36px; line-height: 1.4; font-family: inherit; }' +
'.sa-chat-send { border-radius: 50%; width: 36px; height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; }' + '.sa-chat-send { border-radius: 50%; width: 36px; height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; }' +
'.sa-history-view, .sa-memory-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }' + '.sa-history-view, .sa-memory-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }' +
'.sa-history-header, .sa-memory-header { padding: 8px 14px; border-bottom: 1px solid #eee; direction: rtl; text-align: right; display: flex; justify-content: space-between; align-items: center; }' + '.sa-history-header, .sa-memory-header { padding: 8px 14px; border-bottom: 1px solid #eee; direction: rtl; text-align: right; display: flex; justify-content: space-between; align-items: center; }' +
@@ -141,11 +152,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
var cat = $(e.currentTarget).data('category'); var cat = $(e.currentTarget).data('category');
this.filterMemory(cat); this.filterMemory(cat);
}, },
'keypress .sa-chat-input': function (e) { 'keydown .sa-chat-input': function (e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); }
}, },
'input .sa-chat-input': function (e) { 'input .sa-chat-input': function (e) {
this.$el.find('.sa-chat-send').prop('disabled', !e.target.value.trim()); this.$el.find('.sa-chat-send').prop('disabled', !e.target.value.trim());
// Auto-grow textarea
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px';
}, },
'click .sa-action-approve': function (e) { 'click .sa-action-approve': function (e) {
var $btn = $(e.currentTarget); var $btn = $(e.currentTarget);
@@ -158,10 +172,22 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
}, },
afterRender: function () { afterRender: function () {
this.$el.find('.sa-chat-messages').attr('data-empty-text', var self = this;
this.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם העוזר החכם');
this.loadAlertCount(); Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
this._setupRouteListener(); if (!result.enabled) {
self.$el.find('.sa-fab').hide();
return;
}
self.$el.find('.sa-fab').show();
self.$el.find('.sa-chat-messages').attr('data-empty-text',
self.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם שירה');
self.loadAlertCount();
self._setupRouteListener();
}).catch(function () {
self.$el.find('.sa-fab').hide();
});
}, },
_setupRouteListener: function () { _setupRouteListener: function () {
@@ -405,12 +431,12 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
$messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>'); $messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' + var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושב...') + '</div>'); this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...') + '</div>');
$messages.append($loading); $messages.append($loading);
this.scrollToBottom(); this.scrollToBottom();
$btn.prop('disabled', true); $btn.prop('disabled', true);
$input.prop('disabled', true).val(''); $input.prop('disabled', true).val('').css('height', 'auto');
var payload = { var payload = {
message: message, message: message,
@@ -427,19 +453,12 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
$messages.append('<div class="sa-msg sa-msg-assistant">' + self.formatResponse(response.text) + '</div>'); $messages.append('<div class="sa-msg sa-msg-assistant">' + self.formatResponse(response.text) + '</div>');
// Render action items // Auto-refresh panels if actions were executed
if (response.actions && response.actions.length > 0) { if (response.executedActions && response.executedActions.length > 0) {
response.actions.forEach(function (action) { response.executedActions.forEach(function (action) {
var actionHtml = '<div class="sa-msg-action">' + if (action.entityType) {
'<span><span class="fas fa-bolt" style="color: #f57c00; margin-left: 4px;"></span>' + self.refreshCurrentView(action.entityType);
self.escapeHtml(action.displayText || action.tool) + '</span>' + }
'<div class="sa-action-btns">' +
'<button class="btn btn-success btn-xs sa-action-approve" data-conversation-id="' + self.escapeHtml(response.conversationId) +
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-check"></span></button>' +
'<button class="btn btn-danger btn-xs sa-action-reject" data-conversation-id="' + self.escapeHtml(response.conversationId) +
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-times"></span></button>' +
'</div></div>';
$messages.append(actionHtml);
}); });
} }
@@ -468,6 +487,10 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
var cls = approved ? (result.success ? 'sa-msg-assistant' : 'sa-msg-error') : 'sa-msg-loading'; var cls = approved ? (result.success ? 'sa-msg-assistant' : 'sa-msg-error') : 'sa-msg-loading';
$messages.append('<div class="sa-msg ' + cls + '">' + self.escapeHtml(msg) + '</div>'); $messages.append('<div class="sa-msg ' + cls + '">' + self.escapeHtml(msg) + '</div>');
self.scrollToBottom(); self.scrollToBottom();
if (approved && result.success) {
self.refreshCurrentView(result.entityType);
}
}).catch(function () { }).catch(function () {
$messages.append('<div class="sa-msg sa-msg-error">שגיאה בביצוע הפעולה</div>'); $messages.append('<div class="sa-msg sa-msg-error">שגיאה בביצוע הפעולה</div>');
self.scrollToBottom(); self.scrollToBottom();
@@ -595,11 +618,101 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
formatResponse: function (text) { formatResponse: function (text) {
if (!text) return ''; if (!text) return '';
text = this.escapeHtml(text);
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); var self = this;
text = text.replace(/\n/g, '<br>'); var LRM = '\u200E';
text = text.replace(/^- (.+)/gm, '&bull; $1');
return text; // Split into lines for table detection
var lines = text.split('\n');
var result = [];
var i = 0;
while (i < lines.length) {
// Detect markdown table: line with |, followed by separator |---|, followed by more | lines
if (lines[i].indexOf('|') !== -1 && i + 1 < lines.length && /^\|?[\s\-:|]+\|/.test(lines[i + 1])) {
var tableLines = [];
var j = i;
// Collect all contiguous lines that contain |
while (j < lines.length && lines[j].indexOf('|') !== -1) {
tableLines.push(lines[j]);
j++;
}
if (tableLines.length >= 2) {
result.push(self.renderMarkdownTable(tableLines, LRM));
i = j;
continue;
}
}
// Regular line — escape and format
var line = self.escapeHtml(lines[i]);
line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Headings
line = line.replace(/^### (.+)/, '<strong style="font-size: 14px;">$1</strong>');
line = line.replace(/^## (.+)/, '<strong style="font-size: 15px;">$1</strong>');
// List items
line = line.replace(/^[-*] (.+)/, '&bull; $1');
// Numbered lists
line = line.replace(/^(\d+)\. (.+)/, '$1. $2');
result.push(line);
i++;
}
return result.join('<br>');
},
renderMarkdownTable: function (lines, LRM) {
var self = this;
var parseRow = function (line) {
// Remove leading/trailing |, split by |
var trimmed = line.replace(/^\|/, '').replace(/\|$/, '');
return trimmed.split('|').map(function (cell) { return cell.trim(); });
};
// First line = headers
var headers = parseRow(lines[0]);
// Skip separator line (line[1])
// Remaining lines = data rows
var rows = [];
for (var i = 2; i < lines.length; i++) {
// Skip if it looks like another separator
if (/^[\s\-:|]+$/.test(lines[i].replace(/\|/g, ''))) continue;
rows.push(parseRow(lines[i]));
}
// Build HTML table with LRM for BiDi safety
var html = '<table class="sa-table" dir="rtl">';
// Header
html += '<thead><tr>';
headers.forEach(function (h) {
var escaped = self.escapeHtml(h).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html += '<th>' + LRM + escaped + LRM + '</th>';
});
html += '</tr></thead>';
// Body
html += '<tbody>';
rows.forEach(function (row) {
html += '<tr>';
for (var c = 0; c < headers.length; c++) {
var cell = row[c] !== undefined ? row[c] : '';
var escaped = self.escapeHtml(cell).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html += '<td>' + LRM + escaped + LRM + '</td>';
}
html += '</tr>';
});
html += '</tbody></table>';
return html;
}, },
escapeHtml: function (text) { escapeHtml: function (text) {
@@ -608,5 +721,37 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
}, },
refreshCurrentView: function (entityType) {
var mainView = this.getParentView();
while (mainView && mainView.getParentView()) {
mainView = mainView.getParentView();
}
if (!mainView) return;
var currentView = mainView.getView('main');
if (!currentView) return;
var recordView = currentView.getView('record');
if (recordView && recordView.model) {
recordView.model.trigger('update-all');
}
if (entityType === 'Task' || entityType === 'Meeting') {
var sideView = currentView.getView('side');
if (sideView) {
var activities = sideView.getView('activities');
var tasks = sideView.getView('tasks');
if (activities && activities.actionRefresh) activities.actionRefresh();
if (tasks && tasks.actionRefresh) tasks.actionRefresh();
}
}
},
}); });
}); });
@@ -5,7 +5,7 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
templateContent: '' + templateContent: '' +
'<div class="sa-action-note sa-action-{{status}}" style="padding: 6px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' + '<div class="sa-action-note sa-action-{{status}}" style="padding: 6px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
'<span class="fas {{icon}}" style="margin-left: 6px;"></span>' + '<span class="fas {{icon}}" style="margin-left: 6px;"></span>' +
'{{post}}' + '{{{formattedPost}}}' +
'</div>', '</div>',
data: function () { data: function () {
@@ -13,6 +13,8 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
var noteData = this.model.get('data') || {}; var noteData = this.model.get('data') || {};
var status = noteData.status || 'executed'; var status = noteData.status || 'executed';
data.post = this.model.get('post');
data.formattedPost = this.getHelper().escapeString(data.post || '');
data.status = status; data.status = status;
if (status === 'executed') { if (status === 'executed') {
@@ -5,11 +5,22 @@ define('modules/smart-assistant/views/stream/notes/smart-request', ['views/strea
templateContent: '' + templateContent: '' +
'<div style="background: #e3f2fd; padding: 8px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' + '<div style="background: #e3f2fd; padding: 8px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
'<span class="fas fa-user" style="color: #1565c0; margin-left: 6px;"></span>' + '<span class="fas fa-user" style="color: #1565c0; margin-left: 6px;"></span>' +
'{{post}}' + '{{{formattedPost}}}' +
'</div>', '</div>',
setup: function () { data: function () {
NoteView.prototype.setup.call(this); var data = NoteView.prototype.data.call(this);
data.formattedPost = this.formatPost(this.model.get('post'));
return data;
},
formatPost: function (text) {
if (!text) return '';
text = this.getHelper().escapeString(text);
text = text.replace(/\n/g, '<br>');
return text;
}, },
}); });
}); });
@@ -1,54 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\Modules\NextCloudIntegration\Services\NextCloudService;
class FileStorageFactory
{
private Config $config;
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(Config $config, InjectableFactory $injectableFactory, Log $log)
{
$this->config = $config;
$this->injectableFactory = $injectableFactory;
$this->log = $log;
}
public function create(?string $userId = null): FileStorageInterface
{
$storageType = $this->config->get('assistantFileStorage', 'nextcloud');
return match ($storageType) {
'nextcloud' => $this->createNextCloud($userId),
'webdav' => $this->createWebDav(),
default => throw new Error("Unknown file storage type: {$storageType}"),
};
}
private function createNextCloud(?string $userId): NextCloudFileStorage
{
$nextCloudService = $this->injectableFactory->create(NextCloudService::class);
return new NextCloudFileStorage($nextCloudService->getClientForUser($userId));
}
private function createWebDav(): WebDavFileStorage
{
$url = $this->config->get('assistantWebDavUrl');
$user = $this->config->get('assistantWebDavUser');
$password = $this->config->get('assistantWebDavPassword');
$basePath = $this->config->get('assistantWebDavBasePath', '/webdav');
if (empty($url) || empty($user) || empty($password)) {
throw new Error('WebDAV file storage is not configured.');
}
return new WebDavFileStorage($url, $user, $password, $basePath);
}
}
@@ -1,12 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
interface FileStorageInterface
{
public function listFolder(string $path): array;
public function downloadFile(string $path): string;
public function move(string $source, string $destination): bool;
public function createFolder(string $path): bool;
public function exists(string $path): bool;
}
@@ -1,21 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Modules\NextCloudIntegration\Classes\NextCloud\Client;
class NextCloudFileStorage implements FileStorageInterface
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function listFolder(string $path): array { return $this->client->listFolder($path); }
public function downloadFile(string $path): string { return $this->client->downloadFile($path); }
public function move(string $source, string $destination): bool { return $this->client->move($source, $destination); }
public function createFolder(string $path): bool { return $this->client->createFolderRecursive($path); }
public function exists(string $path): bool { return $this->client->exists($path); }
}
@@ -1,234 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
/**
* Generic WebDAV file storage adapter.
* Works with Synology NAS, ownCloud, and any standard WebDAV server.
* Uses only DAV-standard operations (no NextCloud/OCS-specific extensions).
*/
class WebDavFileStorage implements FileStorageInterface
{
private string $baseUrl;
private string $username;
private string $password;
private string $webdavPath;
public function __construct(
string $baseUrl,
string $username,
string $password,
string $webdavPath = '/webdav'
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->username = $username;
$this->password = $password;
$this->webdavPath = $webdavPath;
}
public function listFolder(string $path): array
{
$propfindXml = '<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>';
$response = $this->request('PROPFIND', $path, $propfindXml, [
'Content-Type: application/xml',
'Depth: 1',
]);
if ($response['status'] !== 207) {
throw new Error("Failed to list folder: HTTP " . $response['status']);
}
return $this->parsePropfindResponse($response['body'], $path);
}
public function downloadFile(string $path): string
{
$response = $this->request('GET', $path);
if ($response['status'] !== 200) {
throw new Error("Failed to download file: HTTP " . $response['status']);
}
return $response['body'];
}
public function move(string $source, string $destination): bool
{
$destUrl = $this->buildUrl($destination);
$response = $this->request('MOVE', $source, null, [
'Destination: ' . $destUrl,
'Overwrite: F',
]);
if ($response['status'] !== 201 && $response['status'] !== 204) {
throw new Error("Failed to move: HTTP " . $response['status']);
}
return true;
}
public function createFolder(string $path): bool
{
$parts = array_filter(explode('/', $path));
$currentPath = '';
foreach ($parts as $part) {
$currentPath .= '/' . $part;
$response = $this->request('MKCOL', $currentPath);
// 201 = created, 405 = already exists
if ($response['status'] !== 201 && $response['status'] !== 405) {
throw new Error("Failed to create folder: HTTP " . $response['status']);
}
}
return true;
}
public function exists(string $path): bool
{
try {
$response = $this->request('PROPFIND', $path, null, ['Depth: 0']);
return $response['status'] === 207;
} catch (\Exception $e) {
return false;
}
}
private function buildUrl(string $path): string
{
$pathParts = explode('/', ltrim($path, '/'));
$encodedParts = array_map('rawurlencode', $pathParts);
return $this->baseUrl . $this->webdavPath . '/' . implode('/', $encodedParts);
}
/**
* @return array{status: int, body: string}
*/
private function request(
string $method,
string $path,
?string $body = null,
array $headers = []
): array {
$url = $this->buildUrl($path);
$ch = curl_init();
$allHeaders = array_merge([
'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password),
], $headers);
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $allHeaders,
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Error("WebDAV connection error: $curlError");
}
return [
'status' => $httpCode,
'body' => $responseBody,
];
}
private function parsePropfindResponse(string $xml, string $basePath): array
{
$items = [];
$normalizedBasePath = '/' . trim($basePath, '/');
$doc = new \DOMDocument();
$doc->loadXML($xml);
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('d', 'DAV:');
$responses = $xpath->query('//d:response');
foreach ($responses as $response) {
$hrefNodes = $xpath->query('d:href', $response);
if ($hrefNodes->length === 0) {
continue;
}
$href = urldecode($hrefNodes->item(0)->textContent);
// Extract relative path by removing webdav prefix
$relativePath = $href;
$webdavPrefix = $this->webdavPath . '/';
if (($pos = strpos($href, $webdavPrefix)) !== false) {
$relativePath = substr($href, $pos + strlen($webdavPrefix));
}
$relativePath = '/' . trim($relativePath, '/');
// Skip the queried folder itself
if ($relativePath === $normalizedBasePath || $relativePath === $normalizedBasePath . '/') {
continue;
}
$propstat = $xpath->query('d:propstat/d:prop', $response)->item(0);
if (!$propstat) {
continue;
}
$resourceType = $xpath->query('d:resourcetype/d:collection', $propstat);
$isFolder = $resourceType->length > 0;
$contentLength = $xpath->query('d:getcontentlength', $propstat);
$size = $contentLength->length > 0 ? (int) $contentLength->item(0)->textContent : null;
$lastModified = $xpath->query('d:getlastmodified', $propstat);
$modified = $lastModified->length > 0 ? $lastModified->item(0)->textContent : null;
$contentType = $xpath->query('d:getcontenttype', $propstat);
$mimeType = $contentType->length > 0 ? $contentType->item(0)->textContent : null;
$items[] = [
'name' => basename($relativePath),
'path' => $relativePath,
'isFolder' => $isFolder,
'size' => $size,
'mimeType' => $isFolder ? 'folder' : $mimeType,
'modified' => $modified,
];
}
usort($items, function ($a, $b) {
if ($a['isFolder'] !== $b['isFolder']) {
return $b['isFolder'] <=> $a['isFolder'];
}
return strcasecmp($a['name'], $b['name']);
});
return $items;
}
}
@@ -34,6 +34,11 @@ class SmartAssistant
} }
} }
public function getActionStatus(Request $request, Response $response): array
{
return $this->getService()->getStatus();
}
public function postActionChat(Request $request, Response $response): array public function postActionChat(Request $request, Response $response): array
{ {
$this->checkAccess(); $this->checkAccess();
@@ -3,6 +3,7 @@
"fields": { "fields": {
"webhookUrl": "Webhook URL", "webhookUrl": "Webhook URL",
"apiKey": "API Key", "apiKey": "API Key",
"espocrmApiKey": "EspoCRM API Key",
"maxMessagesPerHour": "Max Messages Per Hour", "maxMessagesPerHour": "Max Messages Per Hour",
"inactivityWarningDays": "Inactivity Warning Days", "inactivityWarningDays": "Inactivity Warning Days",
"inactivityCriticalDays": "Inactivity Critical Days", "inactivityCriticalDays": "Inactivity Critical Days",
@@ -3,6 +3,7 @@
"fields": { "fields": {
"webhookUrl": "כתובת Webhook", "webhookUrl": "כתובת Webhook",
"apiKey": "מפתח API", "apiKey": "מפתח API",
"espocrmApiKey": "מפתח API של EspoCRM",
"maxMessagesPerHour": "מקסימום הודעות לשעה", "maxMessagesPerHour": "מקסימום הודעות לשעה",
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות", "inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
"inactivityCriticalDays": "ימים לאזהרה קריטית", "inactivityCriticalDays": "ימים לאזהרה קריטית",
@@ -1,19 +1,19 @@
{ {
"labels": { "labels": {
"Smart Assistant": "עוזר חכם", "Smart Assistant": "שירה",
"Office Mode": "מצב משרד", "Office Mode": "מצב משרד",
"Case Mode": "מצב תיק", "Case Mode": "מצב תיק",
"Add Memory": "הוסף זיכרון", "Add Memory": "הוספת זיכרון",
"Search Memory": פש בזיכרון", "Search Memory": יפוש בזיכרון",
"New Conversation": "שיחה חדשה", "New Conversation": "שיחה חדשה",
"Conversation History": "היסטוריית שיחות", "Conversation History": "היסטוריית שיחות",
"Previous Conversations": "שיחות קודמות", "Previous Conversations": "שיחות קודמות",
"No previous conversations": "אין שיחות קודמות", "No previous conversations": "אין שיחות קודמות",
"Ask the assistant...": "שאל/י את העוזר...", "Ask the assistant...": "שאל/י את שירה...",
"Thinking": "חושב...", "Thinking": "חושבת...",
"Error": "שגיאה בתקשורת", "Error": "שגיאה בתקשורת",
"Back": "חזרה", "Back": "חזרה",
"Chat with the assistant": "שוחח/י עם העוזר החכם", "Chat with the assistant": "שוחח/י עם שירה",
"Case Memory": "זיכרון תיק", "Case Memory": "זיכרון תיק",
"Pin": "הצמד", "Pin": "הצמד",
"Unpin": "הסר הצמדה", "Unpin": "הסר הצמדה",
@@ -0,0 +1,7 @@
{
"itemViews": {
"SmartRequest": "modules/smart-assistant/views/stream/notes/smart-request",
"SmartResponse": "modules/smart-assistant/views/stream/notes/smart-response",
"SmartAction": "modules/smart-assistant/views/stream/notes/smart-action"
}
}
@@ -8,6 +8,10 @@
"type": "varchar", "type": "varchar",
"maxLength": 255 "maxLength": 255
}, },
"espocrmApiKey": {
"type": "varchar",
"maxLength": 255
},
"maxMessagesPerHour": { "maxMessagesPerHour": {
"type": "int", "type": "int",
"default": 30 "default": 30
@@ -1,4 +1,12 @@
[ [
{
"route": "/SmartAssistant/action/status",
"method": "get",
"params": {
"controller": "SmartAssistant",
"action": "status"
}
},
{ {
"route": "/SmartAssistant/action/chat", "route": "/SmartAssistant/action/chat",
"method": "post", "method": "post",
@@ -42,6 +42,9 @@ class ActionExecutor
'list_documents' => $this->listDocuments($caseId), 'list_documents' => $this->listDocuments($caseId),
'analyze_document' => $this->analyzeDocument($params, $caseId), 'analyze_document' => $this->analyzeDocument($params, $caseId),
'rename_document' => $this->renameDocument($params), 'rename_document' => $this->renameDocument($params),
'upload_document' => $this->uploadDocument($params, $caseId),
'generate_document' => $this->generateDocument($params, $caseId),
'merge_documents' => $this->mergeDocuments($params, $caseId),
'save_memory' => $this->saveMemory($params, $caseId, $userId), 'save_memory' => $this->saveMemory($params, $caseId, $userId),
default => throw new Error("Unknown tool: {$tool}"), default => throw new Error("Unknown tool: {$tool}"),
}; };
@@ -76,26 +79,48 @@ class ActionExecutor
private function createTask(array $params, ?string $caseId, string $userId): array private function createTask(array $params, ?string $caseId, string $userId): array
{ {
$task = $this->entityManager->getNewEntity('Task'); $taskData = [
$task->set([
'name' => $params['name'] ?? 'משימה חדשה', 'name' => $params['name'] ?? 'משימה חדשה',
'status' => 'Not Started', 'status' => 'Not Started',
'priority' => $params['priority'] ?? 'Normal', 'priority' => $params['priority'] ?? 'Normal',
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null), 'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
'description' => $params['description'] ?? null, 'description' => $params['description'] ?? null,
'parentType' => 'Case', 'parentId' => $caseId,
'assignedUserId' => $params['assignedUserId'] ?? $userId, 'assignedUserId' => $params['assignedUserId'] ?? $userId,
]); ];
if ($caseId) {
$taskData['parentType'] = 'Case';
$taskData['parentId'] = $caseId;
}
$task = $this->entityManager->getNewEntity('Task');
$task->set($taskData);
$this->entityManager->saveEntity($task); $this->entityManager->saveEntity($task);
return ['success' => true, 'message' => "משימה \"{$params['name']}\" נוצרה בהצלחה", 'entityType' => 'Task', 'entityId' => $task->get('id')];
$msg = "✅ משימה \"{$params['name']}\" נוצרה בהצלחה";
if ($caseId) {
$case = $this->entityManager->getEntityById('Case', $caseId);
if ($case) {
$msg .= " בתיק " . $case->get('name');
}
}
return ['success' => true, 'message' => $msg, 'entityType' => 'Task', 'entityId' => $task->get('id')];
} }
private function addNote(array $params, ?string $caseId, string $userId): array private function addNote(array $params, ?string $caseId, string $userId): array
{ {
$noteData = ['type' => 'Post', 'post' => $params['post'] ?? '', 'createdById' => $userId];
if ($caseId) {
$noteData['parentType'] = 'Case';
$noteData['parentId'] = $caseId;
}
$note = $this->entityManager->getNewEntity('Note'); $note = $this->entityManager->getNewEntity('Note');
$note->set(['type' => 'Post', 'post' => $params['post'] ?? '', 'parentType' => 'Case', 'parentId' => $caseId, 'createdById' => $userId]); $note->set($noteData);
$this->entityManager->saveEntity($note); $this->entityManager->saveEntity($note);
return ['success' => true, 'message' => 'הערה נוספה לתיק', 'entityType' => 'Note', 'entityId' => $note->get('id')]; return ['success' => true, 'message' => 'הערה נוספה' . ($caseId ? ' לתיק' : ''), 'entityType' => 'Note', 'entityId' => $note->get('id')];
} }
private function changeStatus(array $params, ?string $caseId): array private function changeStatus(array $params, ?string $caseId): array
@@ -119,16 +144,23 @@ class ActionExecutor
private function createMeeting(array $params, ?string $caseId, string $userId): array private function createMeeting(array $params, ?string $caseId, string $userId): array
{ {
$meeting = $this->entityManager->getNewEntity('Meeting'); $meetingData = [
$meeting->set([
'name' => $params['name'] ?? 'פגישה חדשה', 'status' => 'Planned', 'name' => $params['name'] ?? 'פגישה חדשה', 'status' => 'Planned',
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null), 'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null), 'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
'description' => $params['description'] ?? null, 'description' => $params['description'] ?? null,
'parentType' => 'Case', 'parentId' => $caseId, 'assignedUserId' => $userId, 'assignedUserId' => $userId,
]); ];
if ($caseId) {
$meetingData['parentType'] = 'Case';
$meetingData['parentId'] = $caseId;
}
$meeting = $this->entityManager->getNewEntity('Meeting');
$meeting->set($meetingData);
$this->entityManager->saveEntity($meeting); $this->entityManager->saveEntity($meeting);
return ['success' => true, 'message' => "פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')]; return ['success' => true, 'message' => "פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')];
} }
private function scheduleHearing(array $params, ?string $caseId, string $userId): array private function scheduleHearing(array $params, ?string $caseId, string $userId): array
@@ -214,6 +246,99 @@ class ActionExecutor
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result]; return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
} }
private function uploadDocument(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('upload_document requires a case context.');
}
$fileName = $params['fileName'] ?? null;
$content = $params['content'] ?? null;
if (!$fileName) throw new Error('fileName parameter is required.');
if (!$content) throw new Error('content parameter is required (base64-encoded file data).');
$decoded = base64_decode($content, true);
if ($decoded === false) {
throw new Error('content is not valid base64.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$result = $analyzer->uploadDocument($caseId, $fileName, $decoded, $params['folder'] ?? null);
$msg = "✅ מסמך \"{$fileName}\" הועלה בהצלחה";
if (!empty($params['folder'])) {
$msg .= " לתיקייה \"{$params['folder']}\"";
}
return ['success' => true, 'message' => $msg, 'data' => $result];
}
private function generateDocument(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('generate_document requires a case context.');
}
$templateId = $params['templateId'] ?? null;
if (!$templateId) throw new Error('templateId parameter is required.');
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
if (!$template) throw new NotFound("Template {$templateId} not found.");
$documentSubject = $params['documentSubject'] ?? null;
$templateService = $this->injectableFactory->create(
\Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService::class
);
$result = $templateService->createFromTemplate($templateId, 'Case', $caseId, $documentSubject);
if (empty($result['success'])) {
throw new Error('Failed to generate document from template.');
}
$templateName = $template->get('name') ?? 'document';
$fileName = $result['fileName'] ?? "{$templateName}.docx";
$msg = "✅ מסמך \"{$fileName}\" נוצר בהצלחה מתבנית \"{$templateName}\"";
return [
'success' => true,
'message' => $msg,
'data' => ['fileName' => $fileName],
];
}
private function mergeDocuments(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('merge_documents requires a case context.');
}
$documentIds = $params['documentIds'] ?? [];
if (empty($documentIds)) throw new Error('documentIds parameter is required (array of document IDs).');
$documents = [];
foreach ($documentIds as $docId) {
$doc = $this->entityManager->getEntityById('Document', $docId);
if (!$doc) throw new NotFound("Document {$docId} not found.");
$documents[] = $doc;
}
$mergerService = $this->injectableFactory->create(
\Espo\Modules\NetworkStorageIntegration\Services\DocumentMergerService::class
);
$options = $params['options'] ?? [];
$mergedPath = $mergerService->mergeWithLabels($documents, $options);
return [
'success' => true,
'message' => "" . count($documents) . " מסמכים מוזגו בהצלחה",
'data' => ['path' => $mergedPath, 'documentCount' => count($documents)],
];
}
private function normalizeDateTime(?string $value): ?string private function normalizeDateTime(?string $value): ?string
{ {
if ($value === null || $value === '') return null; if ($value === null || $value === '') return null;
@@ -243,6 +243,78 @@ class AlertCalculator
return array_values($userCounts); return array_values($userCounts);
} }
public function getOpenCaseItems(int $limit = 30): array
{
$items = [];
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('createdAt', 'DESC')
->limit(0, $limit)
->find();
$statusLabels = [
'New' => 'חדש', 'Assigned' => 'שובץ', 'Pending' => 'ממתין',
'PendingHearing' => 'ממתין לדיון', 'PendingDecision' => 'ממתין להחלטה',
'PendingResponse' => 'ממתין לתגובה', 'Negotiation' => 'משא ומתן', 'Suspended' => 'מושהה',
];
foreach ($cases as $case) {
$status = $case->get('status');
$label = $statusLabels[$status] ?? $status;
$assigned = $case->get('assignedUserName');
$items[] = [
'type' => 'open_case',
'severity' => 'info',
'caseId' => $case->get('id'),
'caseName' => $case->get('name'),
'message' => $case->get('name') . ' — ' . $label . ($assigned ? ' (' . $assigned . ')' : ''),
];
}
return $items;
}
public function getUpcomingHearingItems(int $days = 7): array
{
$today = date('Y-m-d');
$threshold = date('Y-m-d', strtotime("+{$days} days"));
$items = [];
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'cCourt', 'assignedUserName'])
->where([
'cNextHearing>=' => $today,
'cNextHearing<=' => $threshold,
'status!=' => self::CLOSED_STATUSES,
'deleted' => false,
])
->order('cNextHearing', 'ASC')
->find();
foreach ($cases as $case) {
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
$court = $case->get('cCourt');
$severity = ($daysUntil <= 1) ? 'warning' : 'info';
$msg = $case->get('name') . ' — דיון בעוד ' . $daysUntil . ' ימים';
if ($court) {
$msg .= ' (' . $court . ')';
}
$items[] = [
'type' => 'upcoming_hearing',
'severity' => $severity,
'caseId' => $case->get('id'),
'caseName' => $case->get('name'),
'hearingDate' => $case->get('cNextHearing'),
'daysUntil' => $daysUntil,
'message' => $msg,
];
}
return $items;
}
public function getUpcomingHearings(int $days = 7): array public function getUpcomingHearings(int $days = 7): array
{ {
$today = date('Y-m-d'); $today = date('Y-m-d');
@@ -55,8 +55,7 @@ class ConversationRepository
if (!$entity) return; if (!$entity) return;
} }
$messages = $entity->get('messagesData') ?? []; $messages = json_decode(json_encode($entity->get('messagesData') ?? []), true);
if (is_object($messages)) $messages = (array) $messages;
$newMessage = ['role' => $role, 'content' => $content, 'timestamp' => date('Y-m-d H:i:s')]; $newMessage = ['role' => $role, 'content' => $content, 'timestamp' => date('Y-m-d H:i:s')];
if (!empty($actions)) $newMessage['actions'] = $actions; if (!empty($actions)) $newMessage['actions'] = $actions;
@@ -81,8 +80,7 @@ class ConversationRepository
$entity = $this->entityManager->getRDBRepository('AssistantConversation') $entity = $this->entityManager->getRDBRepository('AssistantConversation')
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne(); ->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
if (!$entity) return []; if (!$entity) return [];
$messages = $entity->get('messagesData') ?? []; return json_decode(json_encode($entity->get('messagesData') ?? []), true);
return is_object($messages) ? (array) $messages : $messages;
} }
public function getRecentConversations(?string $type = null, ?string $caseId = null, int $limit = 20): array public function getRecentConversations(?string $type = null, ?string $caseId = null, int $limit = 20): array
@@ -137,8 +135,7 @@ class ConversationRepository
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne(); ->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
if (!$entity) return; if (!$entity) return;
$data = $entity->get('messagesData') ?? []; $data = json_decode(json_encode($entity->get('messagesData') ?? []), true);
if (is_object($data)) $data = (array) $data;
for ($i = count($data) - 1; $i >= 0; $i--) { for ($i = count($data) - 1; $i >= 0; $i--) {
if (($data[$i]['role'] ?? '') === 'assistant') { $data[$i]['pendingActions'] = $actions; break; } if (($data[$i]['role'] ?? '') === 'assistant') { $data[$i]['pendingActions'] = $actions; break; }
} }
@@ -6,8 +6,8 @@ use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory; use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Log; use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager; use Espo\ORM\EntityManager;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageFactory; use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageInterface; use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
class DocumentAnalyzer class DocumentAnalyzer
{ {
@@ -43,17 +43,19 @@ class DocumentAnalyzer
$casePath = $this->getCaseFolderPath($caseId); $casePath = $this->getCaseFolderPath($caseId);
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []]; if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
$storage = $this->getStorage(); $client = $this->getStorageClient();
$topLevel = $storage->listFolder($casePath); $topLevel = $client->listFolder($casePath);
$folders = []; $rootFiles = []; $totalFiles = 0; $folders = []; $rootFiles = []; $totalFiles = 0;
foreach ($topLevel as $item) { foreach ($topLevel as $item) {
if ($item['isFolder']) { $isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
if ($isFolder) {
$subFiles = []; $subFiles = [];
try { try {
foreach ($storage->listFolder($item['path']) as $sub) { foreach ($client->listFolder($item['path']) as $sub) {
if (!$sub['isFolder']) { $subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'], 'mimeType' => $sub['mimeType'], 'modified' => $sub['modified'] ?? null]; if (!$subIsFolder) {
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null];
$totalFiles++; $totalFiles++;
} }
} }
@@ -62,7 +64,7 @@ class DocumentAnalyzer
} }
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles]; $folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
} else { } else {
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'], 'mimeType' => $item['mimeType'], 'modified' => $item['modified'] ?? null]; $rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'] ?? null, 'mimeType' => $item['mimeType'] ?? null, 'modified' => $item['modified'] ?? null];
$totalFiles++; $totalFiles++;
} }
} }
@@ -72,17 +74,12 @@ class DocumentAnalyzer
public function extractTextContent(string $filePath): ?string public function extractTextContent(string $filePath): ?string
{ {
$storage = $this->getStorage(); $client = $this->getStorageClient();
$parentPath = dirname($filePath);
$filename = basename($filePath); $filename = basename($filePath);
try { $items = $storage->listFolder($parentPath); } catch (\Exception $e) { return null; } try { $content = $client->downloadFile($filePath); } catch (\Exception $e) { return null; }
$fileSize = null; if (strlen($content) > self::MAX_FILE_SIZE) return null;
foreach ($items as $item) { if ($item['name'] === $filename) { $fileSize = $item['size']; break; } }
if ($fileSize !== null && $fileSize > self::MAX_FILE_SIZE) return null;
try { $content = $storage->downloadFile($filePath); } catch (\Exception $e) { return null; }
if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true); if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename; $tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
@@ -107,7 +104,7 @@ class DocumentAnalyzer
public function renameDocument(string $sourcePath, string $newName): array public function renameDocument(string $sourcePath, string $newName): array
{ {
$storage = $this->getStorage(); $client = $this->getStorageClient();
$parentPath = dirname($sourcePath); $parentPath = dirname($sourcePath);
$oldName = basename($sourcePath); $oldName = basename($sourcePath);
@@ -120,21 +117,66 @@ class DocumentAnalyzer
$destination = $parentPath . '/' . $newName; $destination = $parentPath . '/' . $newName;
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName]; if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
$storage->move($sourcePath, $destination); $client->move($sourcePath, $destination);
$this->updateDocumentEntity($sourcePath, $destination); $this->updateDocumentEntity($sourcePath, $destination);
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName]; return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
} }
private function getCaseFolderPath(string $caseId): ?string public function uploadDocument(string $caseId, string $fileName, string $content, ?string $subfolder = null): array
{ {
$case = $this->entityManager->getEntityById('Case', $caseId); $casePath = $this->getCaseFolderPath($caseId);
return $case ? ($case->get('nextCloudFolderPath') ?: null) : null; if (!$casePath) {
throw new Error('Case does not have a document folder configured (networkStorageFolderPath).');
}
$client = $this->getStorageClient();
$targetDir = $casePath;
if ($subfolder) {
$targetDir = $casePath . '/' . trim($subfolder, '/');
if (!$client->exists($targetDir)) {
$client->createFolderRecursive($targetDir);
}
}
$targetPath = $targetDir . '/' . $fileName;
$client->uploadFile($targetPath, $content);
return [
'path' => $targetPath,
'fileName' => $fileName,
'folder' => $subfolder,
];
} }
private function getStorage(): FileStorageInterface public function getCaseFolderPath(string $caseId): ?string
{ {
return $this->injectableFactory->create(FileStorageFactory::class)->create(); $case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return null;
// Try stored path first
$storedPath = $case->get('networkStorageFolderPath') ?: null;
if ($storedPath) return $storedPath;
// Fall back to computed path from NetworkDocumentService
try {
$nds = $this->getNetworkDocumentService();
return $nds->getEntityFolderPath('Case', $caseId);
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
return null;
}
}
private function getStorageClient(): StorageClientInterface
{
return $this->getNetworkDocumentService()->getClient();
}
private function getNetworkDocumentService(): NetworkDocumentService
{
return $this->injectableFactory->create(NetworkDocumentService::class);
} }
private function extractFromPdf(string $tmpFile): ?string private function extractFromPdf(string $tmpFile): ?string
@@ -168,10 +210,10 @@ class DocumentAnalyzer
private function updateDocumentEntity(string $oldPath, string $newPath): void private function updateDocumentEntity(string $oldPath, string $newPath): void
{ {
try { try {
$doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne(); $doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
if ($doc) { if ($doc) {
$doc->set('nextCloudPath', $newPath); $doc->set('networkStoragePath', $newPath);
$doc->set('nextCloudFilename', basename($newPath)); $doc->set('networkStorageFileName', basename($newPath));
$this->entityManager->saveEntity($doc); $this->entityManager->saveEntity($doc);
} }
} catch (\Exception $e) {} } catch (\Exception $e) {}
@@ -17,7 +17,8 @@ class SmartAssistantService
public const NOTE_TYPE_RESPONSE = 'SmartResponse'; public const NOTE_TYPE_RESPONSE = 'SmartResponse';
public const NOTE_TYPE_ACTION = 'SmartAction'; public const NOTE_TYPE_ACTION = 'SmartAction';
private const READ_ONLY_TOOLS = ['list_documents', 'save_memory']; // All tools execute inline — Shira has full permissions
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
private EntityManager $entityManager; private EntityManager $entityManager;
private InjectableFactory $injectableFactory; private InjectableFactory $injectableFactory;
@@ -103,27 +104,59 @@ class SmartAssistantService
// Call webhook // Call webhook
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode); $response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
// Process actions // Process actions — execute all inline (Shira has full permissions)
$actions = $response['actions'] ?? []; $actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? ''; $responseText = $response['text'] ?? '';
$inlineResults = []; $inlineResults = [];
$remainingActions = []; $executedActions = [];
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
$effectiveCaseId = $caseId;
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
$effectiveCaseId = $context['drillDown']['case']['id'];
}
$executor = $this->injectableFactory->create(ActionExecutor::class);
foreach ($actions as $action) { foreach ($actions as $action) {
$tool = $action['tool'] ?? ''; $tool = $action['tool'] ?? '';
if (in_array($tool, self::READ_ONLY_TOOLS, true)) { $actionCaseId = $effectiveCaseId;
try {
$executor = $this->injectableFactory->create(ActionExecutor::class); // Resolve case from action params if not available
$result = $executor->execute($tool, $action['params'] ?? [], $caseId, $userId); if (!$actionCaseId && !empty($action['params']['caseId'])) {
if (!empty($result['message'])) { $actionCaseId = $action['params']['caseId'];
$inlineResults[] = $result['message']; }
}
} catch (\Exception $e) { // Skip tools that require a case if no case context available
$this->log->warning("SmartAssistant: Inline execution of {$tool} failed: " . $e->getMessage()); if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage(); $inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
continue;
}
try {
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
} }
} else { $executedActions[] = [
$remainingActions[] = $action; 'tool' => $tool,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
];
// Create stream note for executed action in case context
if ($actionCaseId) {
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
'conversationId' => $conversationId,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
]);
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
} }
} }
@@ -131,25 +164,9 @@ class SmartAssistantService
$responseText .= "\n\n" . implode("\n\n", $inlineResults); $responseText .= "\n\n" . implode("\n\n", $inlineResults);
} }
// Store pending actions
if (!empty($remainingActions)) {
self::$conversations[$conversationId] = [
'caseId' => $caseId,
'userId' => $userId,
'actions' => $remainingActions,
];
try {
$this->getConversationRepo()->storePendingActions($conversationId, $caseId ?? '', $userId, $remainingActions);
} catch (\Exception $e) {
$this->log->warning('SmartAssistant: Failed to persist actions: ' . $e->getMessage());
$this->saveConversationFile($conversationId, $caseId, $userId, $remainingActions);
}
}
// Persist response // Persist response
try { try {
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $remainingActions); $this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $executedActions);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage()); $this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage());
} }
@@ -158,14 +175,15 @@ class SmartAssistantService
if ($mode === 'case' && $caseId) { if ($mode === 'case' && $caseId) {
$this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [ $this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [
'conversationId' => $conversationId, 'conversationId' => $conversationId,
'actions' => $remainingActions, 'actions' => $executedActions,
]); ]);
} }
return [ return [
'conversationId' => $conversationId, 'conversationId' => $conversationId,
'text' => $responseText, 'text' => $responseText,
'actions' => $remainingActions, 'actions' => [],
'executedActions' => $executedActions,
]; ];
} }
@@ -236,14 +254,27 @@ class SmartAssistantService
} }
} }
public function getStatus(): array
{
$webhookUrl = $this->getWebhookUrl();
return [
'enabled' => $webhookUrl !== null,
'webhookConfigured' => !empty($webhookUrl),
];
}
public function getSummary(): array public function getSummary(): array
{ {
$alertCalc = $this->injectableFactory->create(AlertCalculator::class); $alertCalc = $this->injectableFactory->create(AlertCalculator::class);
$thresholds = $this->getThresholds(); $thresholds = $this->getThresholds();
$hearingDays = $thresholds['upcomingHearingDays'] ?? 7;
return [ return [
'summary' => $alertCalc->getSummary(), 'summary' => $alertCalc->getSummary(),
'alerts' => $alertCalc->calculateAlerts($thresholds), 'alerts' => $alertCalc->calculateAlerts($thresholds),
'openCases' => $alertCalc->getOpenCaseItems(),
'upcomingHearings' => $alertCalc->getUpcomingHearingItems($hearingDays),
]; ];
} }
@@ -343,12 +374,18 @@ class SmartAssistantService
throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.'); throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.');
} }
$siteUrl = $this->config->get('siteUrl') ?? '';
$payload = json_encode([ $payload = json_encode([
'message' => $message, 'message' => $message,
'context' => $context, 'context' => $context,
'conversationId' => $conversationId, 'conversationId' => $conversationId,
'conversationHistory' => $history, 'conversationHistory' => $history,
'mode' => $mode, 'mode' => $mode,
'espocrm' => [
'url' => rtrim($siteUrl, '/'),
'apiKey' => $this->getEspocrmApiKey(),
],
]); ]);
$headers = ['Content-Type: application/json', 'Accept: application/json']; $headers = ['Content-Type: application/json', 'Accept: application/json'];
@@ -406,14 +443,11 @@ class SmartAssistantService
private function getWebhookUrl(): ?string private function getWebhookUrl(): ?string
{ {
// Try SmartAssistant first, then fallback to old integrations $integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) { if ($integration && $integration->get('enabled')) {
$integration = $this->entityManager->getEntityById('Integration', $name); $data = $integration->get('data') ?? (object) [];
if ($integration && $integration->get('enabled')) { if (!empty($data->webhookUrl)) {
$data = $integration->get('data') ?? (object) []; return $data->webhookUrl;
if (!empty($data->webhookUrl)) {
return $data->webhookUrl;
}
} }
} }
return null; return null;
@@ -421,13 +455,23 @@ class SmartAssistantService
private function getApiKey(): ?string private function getApiKey(): ?string
{ {
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) { $integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
$integration = $this->entityManager->getEntityById('Integration', $name); if ($integration && $integration->get('enabled')) {
if ($integration && $integration->get('enabled')) { $data = $integration->get('data') ?? (object) [];
$data = $integration->get('data') ?? (object) []; if (!empty($data->apiKey)) {
if (!empty($data->apiKey)) { return $data->apiKey;
return $data->apiKey; }
} }
return null;
}
private function getEspocrmApiKey(): ?string
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->espocrmApiKey)) {
return $data->espocrmApiKey;
} }
} }
return null; return null;
@@ -435,16 +479,14 @@ class SmartAssistantService
private function getThresholds(): array private function getThresholds(): array
{ {
foreach (['SmartAssistant', 'OfficeAssistant'] as $name) { $integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
$integration = $this->entityManager->getEntityById('Integration', $name); if ($integration && $integration->get('enabled')) {
if ($integration && $integration->get('enabled')) { $data = (array) ($integration->get('data') ?? []);
$data = (array) ($integration->get('data') ?? []); return [
return [ 'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14, 'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30, 'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7, ];
];
}
} }
return []; return [];
} }
+4 -4
View File
@@ -1,13 +1,13 @@
{ {
"name": "Smart Assistant", "name": "SmartAssistant",
"module": "SmartAssistant", "module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and n8n integration", "description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear", "author": "klear",
"version": "1.0.4", "version": "2.3.0",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"releaseDate": "2026-03-31", "releaseDate": "2026-04-05",
"php": [ "php": [
">=8.1" ">=8.1"
] ]