Compare commits

...

10 Commits

Author SHA1 Message Date
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
11 changed files with 170 additions and 48 deletions
+1 -3
View File
@@ -1,12 +1,10 @@
# SmartAssistant - עוזר חכם
**גרסה:** 1.0.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
**גרסה:** 1.1.7 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
מחליף את CrmAssistant + OfficeAssistant (deprecated).
## תלויות
- Webhook חיצוני (n8n או דומה) לעיבוד AI
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
@@ -5,7 +5,7 @@ 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: '' +
@@ -45,6 +45,8 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) {
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 () {});
@@ -74,6 +76,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
},
showDetail: function (type) {
var self = this;
var $detail = this.$el.find('.sa-dashlet-detail');
if (!type || type === this._activeType && $detail.html()) {
@@ -93,11 +96,11 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
if (type === 'overdue') {
items = alerts.filter(function (a) { return a.type === 'overdue_task'; });
} else if (type === 'hearings') {
items = alerts.filter(function (a) { return a.type === 'hearing_no_prep'; });
items = this._upcomingHearings || [];
} else if (type === 'attention') {
items = alerts.filter(function (a) { return a.severity === 'critical' || a.severity === 'warning'; });
} else if (type === 'open') {
items = alerts.filter(function (a) { return a.type === 'inactive_case' || a.type === 'unassigned_case'; });
items = this._openCases || [];
}
if (items.length === 0) {
@@ -117,7 +120,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
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>' +
link + Espo.Utils.escapeString(a.message) + linkEnd +
link + self.escapeString(a.message) + linkEnd +
'</div>';
});
@@ -480,6 +480,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();
@@ -620,5 +624,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;
},
});
});
@@ -1,6 +1,6 @@
{
"labels": {
"Smart Assistant": "עוזר חכם",
"Smart Assistant": "שירה",
"Office Mode": "מצב משרד",
"Case Mode": "מצב תיק",
"Add Memory": "הוסף זיכרון",
@@ -9,11 +9,11 @@
"Conversation History": "היסטוריית שיחות",
"Previous Conversations": "שיחות קודמות",
"No previous conversations": "אין שיחות קודמות",
"Ask the assistant...": "שאל/י את העוזר...",
"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"
}
}
@@ -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; }
}
@@ -250,10 +250,13 @@ class SmartAssistantService
{
$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),
];
}
@@ -416,14 +419,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;
@@ -431,13 +431,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;
@@ -445,16 +443,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 [];
}
+3 -3
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",
"author": "klear",
"version": "1.0.8",
"version": "1.1.7",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-03-31",
"releaseDate": "2026-03-29",
"php": [
">=8.1"
]