Compare commits

...

9 Commits

Author SHA1 Message Date
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
11 changed files with 286 additions and 95 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# SmartAssistant - עוזר חכם # SmartAssistant - עוזר חכם
**גרסה:** 1.1.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0 **גרסה:** 1.1.7 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור ## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM. עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -76,6 +76,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
}, },
showDetail: function (type) { showDetail: function (type) {
var self = this;
var $detail = this.$el.find('.sa-dashlet-detail'); var $detail = this.$el.find('.sa-dashlet-detail');
if (!type || type === this._activeType && $detail.html()) { if (!type || type === this._activeType && $detail.html()) {
@@ -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);
@@ -168,7 +182,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
self.$el.find('.sa-fab').show(); self.$el.find('.sa-fab').show();
self.$el.find('.sa-chat-messages').attr('data-empty-text', self.$el.find('.sa-chat-messages').attr('data-empty-text',
self.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם העוזר החכם'); self.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם שירה');
self.loadAlertCount(); self.loadAlertCount();
self._setupRouteListener(); self._setupRouteListener();
}).catch(function () { }).catch(function () {
@@ -417,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,
@@ -439,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);
}); });
} }
@@ -480,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();
@@ -607,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) {
@@ -620,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;
}, },
}); });
}); });
@@ -3,14 +3,14 @@
"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": "שוחח/י עם שירה",
@@ -76,26 +76,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 +141,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
@@ -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; }
} }
@@ -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', '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,
]; ];
} }
+3 -3
View File
@@ -1,13 +1,13 @@
{ {
"name": "SmartAssistant", "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.1.3", "version": "2.1.2",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"releaseDate": "2026-03-29", "releaseDate": "2026-04-04",
"php": [ "php": [
">=8.1" ">=8.1"
] ]