Compare commits

...

31 Commits

Author SHA1 Message Date
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
chaim b9abe5f921 release: v1.0.4 — add AssistantConversation entity 2026-03-31 20:33:21 +00:00
chaim a3801deb55 feat: add AssistantConversation i18n (fa_IR/Hebrew) 2026-03-31 20:33:05 +00:00
chaim 300cc58a97 feat: add AssistantConversation i18n (en_US) 2026-03-31 20:33:01 +00:00
chaim c1ea2b7868 feat: add AssistantConversation scope definition 2026-03-31 20:32:53 +00:00
chaim 184cd19285 feat: add AssistantConversation entity definition for chat persistence 2026-03-31 20:32:49 +00:00
chaim 2c34d56c64 release: v1.0.3 2026-03-29 21:34:38 +00:00
chaim d5c8a60193 fix: add clientModule to module.json for client-side template resolution
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:34:18 +00:00
21 changed files with 517 additions and 146 deletions
+1 -3
View File
@@ -1,12 +1,10 @@
# SmartAssistant - עוזר חכם
**גרסה:** 1.0.2 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
**גרסה:** 1.1.7 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
מחליף את CrmAssistant + OfficeAssistant (deprecated).
## תלויות
- Webhook חיצוני (n8n או דומה) לעיבוד AI
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -5,38 +5,68 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
name: 'SmartAssistant',
getTitle: function () {
return this.translate('Smart Assistant', 'labels', 'SmartAssistant') || 'Smart Assistant';
return 'שירה — עוזרת אישית';
},
templateContent: '' +
'<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-alerts" style="max-height: 200px; overflow-y: auto;"></div>' +
'<div class="sa-dashlet-detail" style="max-height: 250px; overflow-y: auto;"></div>' +
'</div>',
events: {
'click .sa-card': function (e) {
var type = $(e.currentTarget).data('type');
this.showDetail(type);
},
},
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 () {
var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) {
self.renderCards(response.summary || {});
self.renderAlerts(response.alerts || []);
self._summary = response.summary || {};
self._alerts = response.alerts || [];
self._openCases = response.openCases || [];
self._upcomingHearings = response.upcomingHearings || [];
self.renderCards(self._summary);
self.showDetail(self._activeType);
}).catch(function () {});
},
renderCards: function (summary) {
var cards = [
{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'},
{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: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
{type: 'overdue', label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'},
{type: 'hearings', label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'},
{type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
];
var self = this;
var html = '';
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: 11px; color: ' + c.color + ';"><span class="fas ' + c.icon + '"></span> ' + c.label + '</div>' +
'</div>';
@@ -45,10 +75,36 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
this.$el.find('.sa-dashlet-cards').html(html);
},
renderAlerts: function (alerts) {
var $alerts = this.$el.find('.sa-dashlet-alerts');
if (alerts.length === 0) {
$alerts.html('<div style="text-align: center; color: #4caf50; padding: 12px;">אין התראות</div>');
showDetail: function (type) {
var self = this;
var $detail = this.$el.find('.sa-dashlet-detail');
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;
}
@@ -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 html = '';
alerts.slice(0, 10).forEach(function (a) {
items.forEach(function (a) {
var color = severityColors[a.severity] || '#333';
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>' + Espo.Utils.escapeString(a.message) + '</span>' +
link + self.escapeString(a.message) + linkEnd +
'</div>';
});
$alerts.html(html);
$detail.html(html);
},
actionRefresh: function () {
this.loadSummary();
},
getActionItemDataList: function () {
return [
{
name: 'refresh',
iconHtml: '<span class="fas fa-sync-alt"></span>',
},
];
},
getColor: function () {
return '#5c6bc0';
},
});
});
@@ -4,12 +4,13 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
templateContent: '' +
'<div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-robot"></span>' +
' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" class="sa-fab-avatar" alt="שירה">' +
' <span class="sa-fab-badge" style="display:none;"></span>' +
'</div>' +
'<div class="sa-chat-panel" style="display:none;">' +
' <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>' +
' <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;">' +
@@ -50,7 +51,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
' <div class="sa-chat-summary" style="display:none;"></div>' +
' <div class="sa-chat-messages"></div>' +
' <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>' +
' <span class="fas fa-paper-plane"></span>' +
' </button>' +
@@ -59,8 +60,9 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
'</div>' +
'<style>' +
'.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:hover { background: #3f51b5; transform: scale(1.08); }' +
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: #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; position: relative; overflow: hidden; }' +
'.sa-fab:hover { transform: scale(1.08); }' +
'.sa-fab-avatar { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }' +
'.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-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); } }' +
@@ -76,14 +78,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-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-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-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-action-btns { display: flex; gap: 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 { flex: 1; border-radius: 20px; padding: 8px 14px; font-size: 13px; 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: 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-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; }' +
@@ -141,11 +149,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
var cat = $(e.currentTarget).data('category');
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(); }
},
'input .sa-chat-input': function (e) {
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) {
var $btn = $(e.currentTarget);
@@ -158,10 +169,22 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
},
afterRender: function () {
this.$el.find('.sa-chat-messages').attr('data-empty-text',
this.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם העוזר החכם');
this.loadAlertCount();
this._setupRouteListener();
var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
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 () {
@@ -405,12 +428,12 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
$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> ' +
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושב...') + '</div>');
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...') + '</div>');
$messages.append($loading);
this.scrollToBottom();
$btn.prop('disabled', true);
$input.prop('disabled', true).val('');
$input.prop('disabled', true).val('').css('height', 'auto');
var payload = {
message: message,
@@ -427,19 +450,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>');
// Render action items
if (response.actions && response.actions.length > 0) {
response.actions.forEach(function (action) {
var actionHtml = '<div class="sa-msg-action">' +
'<span><span class="fas fa-bolt" style="color: #f57c00; margin-left: 4px;"></span>' +
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);
// Auto-refresh panels if actions were executed
if (response.executedActions && response.executedActions.length > 0) {
response.executedActions.forEach(function (action) {
if (action.entityType) {
self.refreshCurrentView(action.entityType);
}
});
}
@@ -468,6 +484,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';
$messages.append('<div class="sa-msg ' + cls + '">' + self.escapeHtml(msg) + '</div>');
self.scrollToBottom();
if (approved && result.success) {
self.refreshCurrentView(result.entityType);
}
}).catch(function () {
$messages.append('<div class="sa-msg sa-msg-error">שגיאה בביצוע הפעולה</div>');
self.scrollToBottom();
@@ -595,11 +615,101 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
formatResponse: function (text) {
if (!text) return '';
text = this.escapeHtml(text);
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\n/g, '<br>');
text = text.replace(/^- (.+)/gm, '&bull; $1');
return text;
var self = this;
var LRM = '\u200E';
// 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) {
@@ -608,5 +718,37 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
div.textContent = text;
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: '' +
'<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>' +
'{{post}}' +
'{{{formattedPost}}}' +
'</div>',
data: function () {
@@ -13,6 +13,8 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
var noteData = this.model.get('data') || {};
var status = noteData.status || 'executed';
data.post = this.model.get('post');
data.formattedPost = this.getHelper().escapeString(data.post || '');
data.status = status;
if (status === 'executed') {
@@ -5,11 +5,22 @@ define('modules/smart-assistant/views/stream/notes/smart-request', ['views/strea
templateContent: '' +
'<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>' +
'{{post}}' +
'{{{formattedPost}}}' +
'</div>',
setup: function () {
NoteView.prototype.setup.call(this);
data: function () {
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;
},
});
});
@@ -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
{
$this->checkAccess();
@@ -0,0 +1 @@
{"labels":{"Create AssistantConversation":"New Conversation"},"fields":{"name":"Title","conversationKey":"Conversation Key","conversationType":"Type","status":"Status","assignedUser":"User","case":"Case","caseName":"Case Name","messageCount":"Messages","messagesData":"Messages Data","lastMessageAt":"Last Message","lastMessagePreview":"Preview","createdAt":"Created At","modifiedAt":"Modified At","createdBy":"Created By","modifiedBy":"Modified By"},"links":{"assignedUser":"User","case":"Case"},"options":{"conversationType":{"office":"Office","case":"Case"}}}
@@ -0,0 +1 @@
{"labels":{"Create AssistantConversation":"שיחה חדשה"},"fields":{"name":"כותרת","conversationKey":"מזהה שיחה","conversationType":"סוג","status":"סטטוס","assignedUser":"משתמש","case":"תיק","caseName":"שם התיק","messageCount":"הודעות","messagesData":"נתוני הודעות","lastMessageAt":"הודעה אחרונה","lastMessagePreview":"תצוגה מקדימה","createdAt":"נוצר ב","modifiedAt":"עודכן ב","createdBy":"נוצר על ידי","modifiedBy":"עודכן על ידי"},"links":{"assignedUser":"משתמש","case":"תיק"},"options":{"conversationType":{"office":"משרד","case":"תיק"}}}
@@ -1,19 +1,19 @@
{
"labels": {
"Smart Assistant": "עוזר חכם",
"Smart Assistant": "שירה",
"Office Mode": "מצב משרד",
"Case Mode": "מצב תיק",
"Add Memory": "הוסף זיכרון",
"Search Memory": פש בזיכרון",
"Add Memory": "הוספת זיכרון",
"Search Memory": יפוש בזיכרון",
"New Conversation": "שיחה חדשה",
"Conversation History": "היסטוריית שיחות",
"Previous Conversations": "שיחות קודמות",
"No previous conversations": "אין שיחות קודמות",
"Ask the assistant...": "שאל/י את העוזר...",
"Thinking": "חושב...",
"Ask the assistant...": "שאל/י את שירה...",
"Thinking": "חושבת...",
"Error": "שגיאה בתקשורת",
"Back": "חזרה",
"Chat with the assistant": "שוחח/י עם העוזר החכם",
"Chat with the assistant": "שוחח/י עם שירה",
"Case Memory": "זיכרון תיק",
"Pin": "הצמד",
"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"
}
}
@@ -0,0 +1 @@
{"fields":{"name":{"type":"varchar","maxLength":255,"required":true},"conversationKey":{"type":"varchar","maxLength":255,"required":true},"conversationType":{"type":"enum","options":["office","case"],"default":"office","required":true},"status":{"type":"varchar","maxLength":50,"default":"Active"},"assignedUser":{"type":"link"},"case":{"type":"link"},"caseName":{"type":"varchar","maxLength":255},"messageCount":{"type":"int","default":0},"messagesData":{"type":"jsonArray"},"lastMessageAt":{"type":"datetime"},"lastMessagePreview":{"type":"text","maxLength":200},"createdAt":{"type":"datetime","readOnly":true},"modifiedAt":{"type":"datetime","readOnly":true},"createdBy":{"type":"link","readOnly":true},"modifiedBy":{"type":"link","readOnly":true}},"links":{"assignedUser":{"type":"belongsTo","entity":"User"},"case":{"type":"belongsTo","entity":"Case"},"createdBy":{"type":"belongsTo","entity":"User"},"modifiedBy":{"type":"belongsTo","entity":"User"}},"collection":{"orderBy":"lastMessageAt","order":"desc","textFilterFields":["name","lastMessagePreview"]},"indexes":{"conversationKey":{"columns":["conversationKey","deleted"],"type":"unique"},"assignedUser":{"columns":["assignedUserId","deleted"]},"caseId":{"columns":["caseId","deleted"]},"conversationType":{"columns":["conversationType","deleted"]}}}
@@ -0,0 +1 @@
{"entity":true,"object":true,"module":"SmartAssistant","acl":true,"aclActionList":["create","read","edit","delete"],"aclLevelList":["all","team","own","no"],"tab":false,"stream":false,"disabled":false,"importable":false,"customizable":false}
@@ -5,5 +5,6 @@
"espocrm": ">=8.0.0"
},
"author": "klear",
"description": "Unified AI Assistant for Legal CRM"
"description": "Unified AI Assistant for Legal CRM",
"clientModule": "smart-assistant"
}
@@ -1,4 +1,12 @@
[
{
"route": "/SmartAssistant/action/status",
"method": "get",
"params": {
"controller": "SmartAssistant",
"action": "status"
}
},
{
"route": "/SmartAssistant/action/chat",
"method": "post",
@@ -76,26 +76,48 @@ class ActionExecutor
private function createTask(array $params, ?string $caseId, string $userId): array
{
$task = $this->entityManager->getNewEntity('Task');
$task->set([
$taskData = [
'name' => $params['name'] ?? 'משימה חדשה',
'status' => 'Not Started',
'priority' => $params['priority'] ?? 'Normal',
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
'description' => $params['description'] ?? null,
'parentType' => 'Case', 'parentId' => $caseId,
'assignedUserId' => $params['assignedUserId'] ?? $userId,
]);
];
if ($caseId) {
$taskData['parentType'] = 'Case';
$taskData['parentId'] = $caseId;
}
$task = $this->entityManager->getNewEntity('Task');
$task->set($taskData);
$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
{
$noteData = ['type' => 'Post', 'post' => $params['post'] ?? '', 'createdById' => $userId];
if ($caseId) {
$noteData['parentType'] = 'Case';
$noteData['parentId'] = $caseId;
}
$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);
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
@@ -119,16 +141,23 @@ class ActionExecutor
private function createMeeting(array $params, ?string $caseId, string $userId): array
{
$meeting = $this->entityManager->getNewEntity('Meeting');
$meeting->set([
$meetingData = [
'name' => $params['name'] ?? 'פגישה חדשה', 'status' => 'Planned',
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? 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);
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
@@ -243,6 +243,78 @@ class AlertCalculator
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
{
$today = date('Y-m-d');
@@ -55,8 +55,7 @@ class ConversationRepository
if (!$entity) return;
}
$messages = $entity->get('messagesData') ?? [];
if (is_object($messages)) $messages = (array) $messages;
$messages = json_decode(json_encode($entity->get('messagesData') ?? []), true);
$newMessage = ['role' => $role, 'content' => $content, 'timestamp' => date('Y-m-d H:i:s')];
if (!empty($actions)) $newMessage['actions'] = $actions;
@@ -81,8 +80,7 @@ class ConversationRepository
$entity = $this->entityManager->getRDBRepository('AssistantConversation')
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
if (!$entity) return [];
$messages = $entity->get('messagesData') ?? [];
return is_object($messages) ? (array) $messages : $messages;
return json_decode(json_encode($entity->get('messagesData') ?? []), true);
}
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();
if (!$entity) return;
$data = $entity->get('messagesData') ?? [];
if (is_object($data)) $data = (array) $data;
$data = json_decode(json_encode($entity->get('messagesData') ?? []), true);
for ($i = count($data) - 1; $i >= 0; $i--) {
if (($data[$i]['role'] ?? '') === 'assistant') { $data[$i]['pendingActions'] = $actions; break; }
}
@@ -17,7 +17,8 @@ class SmartAssistantService
public const NOTE_TYPE_RESPONSE = 'SmartResponse';
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', 'save_memory'];
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
@@ -103,27 +104,59 @@ class SmartAssistantService
// Call webhook
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
// Process actions
// Process actions — execute all inline (Shira has full permissions)
$actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? '';
$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) {
$tool = $action['tool'] ?? '';
if (in_array($tool, self::READ_ONLY_TOOLS, true)) {
try {
$executor = $this->injectableFactory->create(ActionExecutor::class);
$result = $executor->execute($tool, $action['params'] ?? [], $caseId, $userId);
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Inline execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
$actionCaseId = $effectiveCaseId;
// Resolve case from action params if not available
if (!$actionCaseId && !empty($action['params']['caseId'])) {
$actionCaseId = $action['params']['caseId'];
}
// Skip tools that require a case if no case context available
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
continue;
}
try {
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
} else {
$remainingActions[] = $action;
$executedActions[] = [
'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);
}
// 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
try {
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $remainingActions);
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $executedActions);
} catch (\Exception $e) {
$this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage());
}
@@ -158,14 +175,15 @@ class SmartAssistantService
if ($mode === 'case' && $caseId) {
$this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [
'conversationId' => $conversationId,
'actions' => $remainingActions,
'actions' => $executedActions,
]);
}
return [
'conversationId' => $conversationId,
'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
{
$alertCalc = $this->injectableFactory->create(AlertCalculator::class);
$thresholds = $this->getThresholds();
$hearingDays = $thresholds['upcomingHearingDays'] ?? 7;
return [
'summary' => $alertCalc->getSummary(),
'alerts' => $alertCalc->calculateAlerts($thresholds),
'openCases' => $alertCalc->getOpenCaseItems(),
'upcomingHearings' => $alertCalc->getUpcomingHearingItems($hearingDays),
];
}
@@ -406,14 +437,11 @@ class SmartAssistantService
private function getWebhookUrl(): ?string
{
// Try SmartAssistant first, then fallback to old integrations
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
$integration = $this->entityManager->getEntityById('Integration', $name);
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->webhookUrl)) {
return $data->webhookUrl;
}
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->webhookUrl)) {
return $data->webhookUrl;
}
}
return null;
@@ -421,13 +449,11 @@ class SmartAssistantService
private function getApiKey(): ?string
{
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
$integration = $this->entityManager->getEntityById('Integration', $name);
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->apiKey)) {
return $data->apiKey;
}
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->apiKey)) {
return $data->apiKey;
}
}
return null;
@@ -435,16 +461,14 @@ class SmartAssistantService
private function getThresholds(): array
{
foreach (['SmartAssistant', 'OfficeAssistant'] as $name) {
$integration = $this->entityManager->getEntityById('Integration', $name);
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
return [
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
];
}
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
return [
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
];
}
return [];
}
+4 -4
View File
@@ -1,13 +1,13 @@
{
"name": "Smart Assistant",
"name": "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",
"version": "1.0.2",
"version": "2.1.0",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-03-29",
"releaseDate": "2026-04-04",
"php": [
">=8.1"
]