Compare commits

...

40 Commits

Author SHA1 Message Date
chaim 0dc26f3b15 feat: SmartAssistant v2.4.0 — create_call tool + behavioral rules system
- Add create_call tool: records phone calls as Call entity (not Meeting)
- Add AssistantRule entity: persistent behavioral rules the AI follows
- Add save_rule tool: AI can save new rules when user requests them
- Add systemInstructions to webhook: call vs meeting guidance, open question tracking
- Add recentCalls to case context for AI awareness
- Add AssistantRuleContextProvider: injects active rules per mode into context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:20:23 +00:00
chaim ca04dd72f8 feat: add plugin tool handler support via metadata lookup
ActionExecutor now checks app.smartAssistant.tools.{name}.handler
metadata for unknown tools before throwing. This allows extensions
to register their own tool handlers without modifying SmartAssistant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:08:39 +00:00
chaim 87bc4b6770 feat: SmartAssistant v2.3.0 — document tools + NetworkStorage integration
- Add upload_document, generate_document, merge_documents tools
- Rewrite DocumentAnalyzer to use NetworkStorageIntegration instead of custom WebDAV client
- Delete FileStorage classes (FileStorageInterface, FileStorageFactory, NextCloudFileStorage, WebDavFileStorage)
- Use networkStorageFolderPath instead of nextCloudFolderPath

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:25:31 +00:00
chaim 1eb856b178 feat: rename assistant to שירה (Shira) 2026-03-31 23:12:31 +00:00
chaim c9d08aa1b9 feat: rename assistant to שירה (Shira) 2026-03-31 23:12:29 +00:00
chaim 233f56eccf feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:59 +00:00
chaim 0157b31249 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:33 +00:00
chaim 49c42e3d20 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:29 +00:00
chaim 4dad65f578 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:22 +00:00
chaim f116611c73 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:09:16 +00:00
chaim ff2388e7e1 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:09:10 +00:00
chaim 92c3cd2cdc release: v1.0.7 2026-03-31 23:01:33 +00:00
chaim 3102bcff77 feat: clickable dashlet cards with detail drill-down 2026-03-31 23:01:19 +00:00
chaim f5c60f4272 release: v1.0.6 2026-03-31 22:56:28 +00:00
chaim 21c3e33635 fix: add getColor to dashlet for EspoCRM v9 compatibility 2026-03-31 22:56:08 +00:00
chaim ffbb648dd5 release: v1.0.5 2026-03-31 22:53:23 +00:00
chaim f915faafdc fix: add getActionItemDataList to dashlet for EspoCRM v9 compatibility 2026-03-31 22:53:10 +00:00
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
40 changed files with 1124 additions and 498 deletions
+120
View File
@@ -0,0 +1,120 @@
# SmartAssistant — פרומפט פריסה לסביבת פיתוח
> **העתק את הפרומפט הבא כהודעה פותחת בשיחה חדשה של Claude Code, מתוך תיקיית הפרויקט.**
---
```
## הקשר
אני עובד על הרחבת SmartAssistant ל-EspoCRM — עוזר AI צף למשרד עורכי דין.
הקוד מוכן (v1.0.3), וצריך להשלים את הפריסה בסביבת הפיתוח.
קרא את DEPLOYMENT_REVIEW.md לסיכום מלא של מה בוצע, מה נבדק, ומה נשאר.
## סביבת הפיתוח
- EspoCRM Dev: https://espocrm.dev.marcus-law.co.il (Coolify UUID: c591uqmu49qa5kh7t2z7a2mp)
- n8n: https://n8n.dev.marcus-law.co.il
- Gitea: https://gitea.dev.marcus-law.co.il (repo: espocrm-extensions/SmartAssistant)
- קרדנשיאלים ב-Infisical (תיקיות /espocrm, /n8n)
## המשימות — שלב 1: הכנה לפריסה
בצע את המשימות הבאות בסדר. לפני כל שלב — עדכן אותי מה אתה הולך לעשות וחכה לאישור.
### 1. בדיקת תאימות סביבה
- בדוק גרסת EspoCRM בסביבת dev (צריך >= 8.0.0)
- בדוק גרסת PHP (צריך >= 8.1)
- בדוק שאין שאריות של מודולים ישנים (CrmAssistant / OfficeAssistant הוסרו מהפרויקט)
### 2. בדיקת אנטיטי AssistantConversation
- המודול משתמש באנטיטי AssistantConversation לשמירת שיחות (ראה ConversationRepository.php)
- בדוק אם האנטיטי הזה קיים ב-EspoCRM Dev
- אם לא קיים — צריך ליצור אותו. בדוק את הקוד ב-ConversationRepository.php כדי להבין אילו שדות נדרשים, והכן entityDefs JSON
### 3. בניית n8n Workflow לטיפול בצ'אט
זה הרכיב הקריטי החסר. המודול שולח webhook ל-n8n ומצפה לתשובה.
**Webhook Payload (נשלח מ-EspoCRM):**
```json
{
"message": "הודעת המשתמש",
"context": { ... הקשר תיק/משרד מלא ... },
"conversationId": "conv_xxx",
"conversationHistory": [{ "role": "user/assistant", "content": "..." }],
"mode": "office|case"
}
```
**Response נדרש (חזרה ל-EspoCRM):**
```json
{
"text": "תשובת העוזר",
"actions": [
{
"tool": "create_task|add_note|change_status|create_meeting|schedule_hearing|list_documents|analyze_document|save_memory|rename_document",
"params": { ... },
"actionId": "action_xxx",
"displayText": "תיאור הפעולה למשתמש"
}
]
}
```
**הפעולות שהעוזר יכול לבקש:**
| כלי | פרמטרים | דורש אישור |
|------|----------|------------|
| create_task | name, dateEnd, priority, description | כן |
| add_note | text | כן |
| change_status | status | כן |
| create_meeting | name, dateStart, dateEnd, description | כן |
| create_call | name, dateStart, dateEnd, description, direction, status | כן |
| schedule_hearing | date, description | כן |
| list_documents | — | לא (מיידי) |
| analyze_document | documentId | כן |
| save_memory | name, content, category, importance | לא (מיידי) |
| save_rule | name, rule, scope (global/case/office) | לא (מיידי) |
| rename_document | documentId, newName | כן |
**דרישות ל-Workflow:**
- Webhook trigger שמקבל POST
- שליחת ההודעה + הקשר ל-LLM (Claude/GPT)
- System prompt שמכיר את הכלים הזמינים ואת פורמט התשובה
- החזרת JSON תקין בפורמט למעלה
- שמירת היסטוריית שיחה (conversationId)
- טיפול בשני המצבים (office/case) עם system prompts מתאימים
### 4. הגדרת Integration ב-EspoCRM
אחרי שה-workflow מוכן:
- Admin > Integrations > Smart Assistant
- webhookUrl = כתובת ה-webhook מ-n8n
- apiKey = מפתח אימות (אם הגדרנו)
- שאר הערכים: ברירת מחדל
### 5. בדיקות ידניות ראשונות
אחרי הפריסה:
- [ ] האם ה-FAB (כפתור צ'אט) מופיע?
- [ ] האם ניתן לשלוח הודעה במצב משרד ולקבל תשובה?
- [ ] האם ניתן לנווט לתיק ולשלוח הודעה במצב תיק?
- [ ] האם פעולות מוצגות לאישור?
- [ ] האם ההתראות מופיעות ב-navbar?
- [ ] האם הדשלט מציג נתונים?
## הנחיות
- השתמש ב-MCP tools (Coolify, Gitea, n8n, Infisical) לכל פעולה
- אל תבצע פעולות הרסניות בלי אישור שלי
- אם נתקלת בבעיה — תאר מה קרה ומה ניסית, ותשאל מה לעשות
- עדכן את DEPLOYMENT_REVIEW.md עם כל ממצא חדש
```
---
## הערות שימוש
- **היכן להריץ:** בתיקיית `/home/chaim/espocrm-extensions/SmartAssistant`
- **MCP נדרשים:** coolify-dev, gitea-dev, n8n-dev, infisical
- **משך משוער:** השלב הקריטי הוא בניית ה-n8n workflow (#3) — זה ידרוש עיצוב system prompts ובדיקות
- **מה הפרומפט לא כולל:** הסרת מודולים ישנים (תלוי בממצאי שלב 1), הוספת תרגום he_IL (שיפור עתידי)
+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: 27 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';
},
});
});
@@ -3,13 +3,16 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
return View.extend({
templateContent: '' +
'<div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-robot"></span>' +
'<div class="sa-fab-wrapper">' +
' <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>' +
'</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 +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-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,9 +62,11 @@ 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-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-wrapper { position: relative; display: inline-block; }' +
'.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: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; }' +
'@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; }' +
@@ -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-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 +152,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 +172,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 +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>');
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 +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>');
// 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 +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';
$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 +618,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 +721,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,54 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\Modules\NextCloudIntegration\Services\NextCloudService;
class FileStorageFactory
{
private Config $config;
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(Config $config, InjectableFactory $injectableFactory, Log $log)
{
$this->config = $config;
$this->injectableFactory = $injectableFactory;
$this->log = $log;
}
public function create(?string $userId = null): FileStorageInterface
{
$storageType = $this->config->get('assistantFileStorage', 'nextcloud');
return match ($storageType) {
'nextcloud' => $this->createNextCloud($userId),
'webdav' => $this->createWebDav(),
default => throw new Error("Unknown file storage type: {$storageType}"),
};
}
private function createNextCloud(?string $userId): NextCloudFileStorage
{
$nextCloudService = $this->injectableFactory->create(NextCloudService::class);
return new NextCloudFileStorage($nextCloudService->getClientForUser($userId));
}
private function createWebDav(): WebDavFileStorage
{
$url = $this->config->get('assistantWebDavUrl');
$user = $this->config->get('assistantWebDavUser');
$password = $this->config->get('assistantWebDavPassword');
$basePath = $this->config->get('assistantWebDavBasePath', '/webdav');
if (empty($url) || empty($user) || empty($password)) {
throw new Error('WebDAV file storage is not configured.');
}
return new WebDavFileStorage($url, $user, $password, $basePath);
}
}
@@ -1,12 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
interface FileStorageInterface
{
public function listFolder(string $path): array;
public function downloadFile(string $path): string;
public function move(string $source, string $destination): bool;
public function createFolder(string $path): bool;
public function exists(string $path): bool;
}
@@ -1,21 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Modules\NextCloudIntegration\Classes\NextCloud\Client;
class NextCloudFileStorage implements FileStorageInterface
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function listFolder(string $path): array { return $this->client->listFolder($path); }
public function downloadFile(string $path): string { return $this->client->downloadFile($path); }
public function move(string $source, string $destination): bool { return $this->client->move($source, $destination); }
public function createFolder(string $path): bool { return $this->client->createFolderRecursive($path); }
public function exists(string $path): bool { return $this->client->exists($path); }
}
@@ -1,234 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
/**
* Generic WebDAV file storage adapter.
* Works with Synology NAS, ownCloud, and any standard WebDAV server.
* Uses only DAV-standard operations (no NextCloud/OCS-specific extensions).
*/
class WebDavFileStorage implements FileStorageInterface
{
private string $baseUrl;
private string $username;
private string $password;
private string $webdavPath;
public function __construct(
string $baseUrl,
string $username,
string $password,
string $webdavPath = '/webdav'
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->username = $username;
$this->password = $password;
$this->webdavPath = $webdavPath;
}
public function listFolder(string $path): array
{
$propfindXml = '<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>';
$response = $this->request('PROPFIND', $path, $propfindXml, [
'Content-Type: application/xml',
'Depth: 1',
]);
if ($response['status'] !== 207) {
throw new Error("Failed to list folder: HTTP " . $response['status']);
}
return $this->parsePropfindResponse($response['body'], $path);
}
public function downloadFile(string $path): string
{
$response = $this->request('GET', $path);
if ($response['status'] !== 200) {
throw new Error("Failed to download file: HTTP " . $response['status']);
}
return $response['body'];
}
public function move(string $source, string $destination): bool
{
$destUrl = $this->buildUrl($destination);
$response = $this->request('MOVE', $source, null, [
'Destination: ' . $destUrl,
'Overwrite: F',
]);
if ($response['status'] !== 201 && $response['status'] !== 204) {
throw new Error("Failed to move: HTTP " . $response['status']);
}
return true;
}
public function createFolder(string $path): bool
{
$parts = array_filter(explode('/', $path));
$currentPath = '';
foreach ($parts as $part) {
$currentPath .= '/' . $part;
$response = $this->request('MKCOL', $currentPath);
// 201 = created, 405 = already exists
if ($response['status'] !== 201 && $response['status'] !== 405) {
throw new Error("Failed to create folder: HTTP " . $response['status']);
}
}
return true;
}
public function exists(string $path): bool
{
try {
$response = $this->request('PROPFIND', $path, null, ['Depth: 0']);
return $response['status'] === 207;
} catch (\Exception $e) {
return false;
}
}
private function buildUrl(string $path): string
{
$pathParts = explode('/', ltrim($path, '/'));
$encodedParts = array_map('rawurlencode', $pathParts);
return $this->baseUrl . $this->webdavPath . '/' . implode('/', $encodedParts);
}
/**
* @return array{status: int, body: string}
*/
private function request(
string $method,
string $path,
?string $body = null,
array $headers = []
): array {
$url = $this->buildUrl($path);
$ch = curl_init();
$allHeaders = array_merge([
'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password),
], $headers);
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $allHeaders,
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Error("WebDAV connection error: $curlError");
}
return [
'status' => $httpCode,
'body' => $responseBody,
];
}
private function parsePropfindResponse(string $xml, string $basePath): array
{
$items = [];
$normalizedBasePath = '/' . trim($basePath, '/');
$doc = new \DOMDocument();
$doc->loadXML($xml);
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('d', 'DAV:');
$responses = $xpath->query('//d:response');
foreach ($responses as $response) {
$hrefNodes = $xpath->query('d:href', $response);
if ($hrefNodes->length === 0) {
continue;
}
$href = urldecode($hrefNodes->item(0)->textContent);
// Extract relative path by removing webdav prefix
$relativePath = $href;
$webdavPrefix = $this->webdavPath . '/';
if (($pos = strpos($href, $webdavPrefix)) !== false) {
$relativePath = substr($href, $pos + strlen($webdavPrefix));
}
$relativePath = '/' . trim($relativePath, '/');
// Skip the queried folder itself
if ($relativePath === $normalizedBasePath || $relativePath === $normalizedBasePath . '/') {
continue;
}
$propstat = $xpath->query('d:propstat/d:prop', $response)->item(0);
if (!$propstat) {
continue;
}
$resourceType = $xpath->query('d:resourcetype/d:collection', $propstat);
$isFolder = $resourceType->length > 0;
$contentLength = $xpath->query('d:getcontentlength', $propstat);
$size = $contentLength->length > 0 ? (int) $contentLength->item(0)->textContent : null;
$lastModified = $xpath->query('d:getlastmodified', $propstat);
$modified = $lastModified->length > 0 ? $lastModified->item(0)->textContent : null;
$contentType = $xpath->query('d:getcontenttype', $propstat);
$mimeType = $contentType->length > 0 ? $contentType->item(0)->textContent : null;
$items[] = [
'name' => basename($relativePath),
'path' => $relativePath,
'isFolder' => $isFolder,
'size' => $size,
'mimeType' => $isFolder ? 'folder' : $mimeType,
'modified' => $modified,
];
}
usort($items, function ($a, $b) {
if ($a['isFolder'] !== $b['isFolder']) {
return $b['isFolder'] <=> $a['isFolder'];
}
return strcasecmp($a['name'], $b['name']);
});
return $items;
}
}
@@ -34,6 +34,11 @@ class SmartAssistant
}
}
public function getActionStatus(Request $request, Response $response): array
{
return $this->getService()->getStatus();
}
public function postActionChat(Request $request, Response $response): array
{
$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,22 @@
{
"labels": {
"Create AssistantRule": "Create Rule"
},
"fields": {
"name": "Name",
"rule": "Rule",
"scope": "Scope",
"isActive": "Active",
"createdAt": "Created At",
"modifiedAt": "Modified At",
"createdBy": "Created By",
"modifiedBy": "Modified By"
},
"options": {
"scope": {
"global": "Global",
"case": "Case Mode",
"office": "Office Mode"
}
}
}
@@ -1,10 +1,12 @@
{
"scopeNames": {
"CaseMemory": "Case Memory",
"AssistantRule": "Assistant Rule",
"SmartAssistant": "Smart Assistant"
},
"scopeNamesPlural": {
"CaseMemory": "Case Memories",
"AssistantRule": "Assistant Rules",
"SmartAssistant": "Smart Assistant"
}
}
@@ -3,6 +3,7 @@
"fields": {
"webhookUrl": "Webhook URL",
"apiKey": "API Key",
"espocrmApiKey": "EspoCRM API Key",
"maxMessagesPerHour": "Max Messages Per Hour",
"inactivityWarningDays": "Inactivity Warning Days",
"inactivityCriticalDays": "Inactivity Critical Days",
@@ -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":"תיק"}}}
@@ -0,0 +1,22 @@
{
"labels": {
"Create AssistantRule": "הוסף כלל"
},
"fields": {
"name": "שם",
"rule": "כלל",
"scope": "היקף",
"isActive": "פעיל",
"createdAt": "נוצר בתאריך",
"modifiedAt": "עודכן בתאריך",
"createdBy": "נוצר על ידי",
"modifiedBy": "עודכן על ידי"
},
"options": {
"scope": {
"global": "גלובלי",
"case": "מצב תיק",
"office": "מצב משרד"
}
}
}
@@ -1,10 +1,12 @@
{
"scopeNames": {
"CaseMemory": "זיכרון תיק",
"AssistantRule": "כלל התנהגות",
"SmartAssistant": "עוזר חכם"
},
"scopeNamesPlural": {
"CaseMemory": "זיכרונות תיקים",
"AssistantRule": "כללי התנהגות",
"SmartAssistant": "עוזר חכם"
}
}
@@ -3,6 +3,7 @@
"fields": {
"webhookUrl": "כתובת Webhook",
"apiKey": "מפתח API",
"espocrmApiKey": "מפתח API של EspoCRM",
"maxMessagesPerHour": "מקסימום הודעות לשעה",
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
"inactivityCriticalDays": "ימים לאזהרה קריטית",
@@ -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,56 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 255,
"required": true
},
"rule": {
"type": "text",
"required": true
},
"scope": {
"type": "enum",
"options": ["global", "case", "office"],
"default": "global"
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
}
},
"indexes": {
"isActive": {
"columns": ["isActive", "deleted"]
},
"scope": {
"columns": ["scope", "isActive", "deleted"]
}
}
}
@@ -8,6 +8,10 @@
"type": "varchar",
"maxLength": 255
},
"espocrmApiKey": {
"type": "varchar",
"maxLength": 255
},
"maxMessagesPerHour": {
"type": "int",
"default": 30
@@ -0,0 +1,11 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "scope"}],
[{"name": "isActive"}, false],
[{"name": "rule", "fullWidth": true}],
[{"name": "createdAt"}, {"name": "createdBy"}]
]
}
]
@@ -0,0 +1,7 @@
[
{"name": "name", "width": 35, "link": true},
{"name": "rule", "width": 30},
{"name": "scope", "width": 10},
{"name": "isActive", "width": 10},
{"name": "createdAt", "width": 15}
]
@@ -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}
@@ -0,0 +1,11 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "team", "own", "no"],
"stream": false,
"tab": false,
"disabled": 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",
@@ -7,18 +7,21 @@ use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
class ActionExecutor
{
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Log $log;
private Metadata $metadata;
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log)
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log, Metadata $metadata)
{
$this->entityManager = $entityManager;
$this->injectableFactory = $injectableFactory;
$this->log = $log;
$this->metadata = $metadata;
}
public const VALID_STATUSES = [
@@ -38,12 +41,17 @@ class ActionExecutor
'add_note' => $this->addNote($params, $caseId, $userId),
'change_status' => $this->changeStatus($params, $caseId),
'create_meeting' => $this->createMeeting($params, $caseId, $userId),
'create_call' => $this->createCall($params, $caseId, $userId),
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
'list_documents' => $this->listDocuments($caseId),
'analyze_document' => $this->analyzeDocument($params, $caseId),
'rename_document' => $this->renameDocument($params),
'upload_document' => $this->uploadDocument($params, $caseId),
'generate_document' => $this->generateDocument($params, $caseId),
'merge_documents' => $this->mergeDocuments($params, $caseId),
'save_memory' => $this->saveMemory($params, $caseId, $userId),
default => throw new Error("Unknown tool: {$tool}"),
'save_rule' => $this->saveRule($params, $userId),
default => $this->executePluginTool($tool, $params, $caseId, $userId),
};
}
@@ -74,28 +82,76 @@ class ActionExecutor
];
}
private function saveRule(array $params, string $userId): array
{
$name = $params['name'] ?? '';
$rule = $params['rule'] ?? '';
if (!$name || !$rule) {
throw new Error('save_rule requires name and rule parameters.');
}
$entity = $this->entityManager->getNewEntity('AssistantRule');
$entity->set([
'name' => $name,
'rule' => $rule,
'scope' => $params['scope'] ?? 'global',
'isActive' => true,
'createdById' => $userId,
]);
$this->entityManager->saveEntity($entity);
return [
'success' => true,
'message' => "✅ כלל חדש נשמר: \"{$name}\"",
'entityType' => 'AssistantRule',
'entityId' => $entity->get('id'),
];
}
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 +175,46 @@ 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 createCall(array $params, ?string $caseId, string $userId): array
{
$callData = [
'name' => $params['name'] ?? 'שיחה חדשה',
'status' => $params['status'] ?? 'Held',
'direction' => $params['direction'] ?? 'Outbound',
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
'description' => $params['description'] ?? null,
'assignedUserId' => $userId,
];
if ($caseId) {
$callData['parentType'] = 'Case';
$callData['parentId'] = $caseId;
}
$call = $this->entityManager->getNewEntity('Call');
$call->set($callData);
$this->entityManager->saveEntity($call);
return ['success' => true, 'message' => "✅ שיחה \"{$params['name']}\" תועדה בהצלחה", 'entityType' => 'Call', 'entityId' => $call->get('id')];
}
private function scheduleHearing(array $params, ?string $caseId, string $userId): array
@@ -214,6 +300,99 @@ class ActionExecutor
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
}
private function uploadDocument(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('upload_document requires a case context.');
}
$fileName = $params['fileName'] ?? null;
$content = $params['content'] ?? null;
if (!$fileName) throw new Error('fileName parameter is required.');
if (!$content) throw new Error('content parameter is required (base64-encoded file data).');
$decoded = base64_decode($content, true);
if ($decoded === false) {
throw new Error('content is not valid base64.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$result = $analyzer->uploadDocument($caseId, $fileName, $decoded, $params['folder'] ?? null);
$msg = "✅ מסמך \"{$fileName}\" הועלה בהצלחה";
if (!empty($params['folder'])) {
$msg .= " לתיקייה \"{$params['folder']}\"";
}
return ['success' => true, 'message' => $msg, 'data' => $result];
}
private function generateDocument(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('generate_document requires a case context.');
}
$templateId = $params['templateId'] ?? null;
if (!$templateId) throw new Error('templateId parameter is required.');
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
if (!$template) throw new NotFound("Template {$templateId} not found.");
$documentSubject = $params['documentSubject'] ?? null;
$templateService = $this->injectableFactory->create(
\Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService::class
);
$result = $templateService->createFromTemplate($templateId, 'Case', $caseId, $documentSubject);
if (empty($result['success'])) {
throw new Error('Failed to generate document from template.');
}
$templateName = $template->get('name') ?? 'document';
$fileName = $result['fileName'] ?? "{$templateName}.docx";
$msg = "✅ מסמך \"{$fileName}\" נוצר בהצלחה מתבנית \"{$templateName}\"";
return [
'success' => true,
'message' => $msg,
'data' => ['fileName' => $fileName],
];
}
private function mergeDocuments(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('merge_documents requires a case context.');
}
$documentIds = $params['documentIds'] ?? [];
if (empty($documentIds)) throw new Error('documentIds parameter is required (array of document IDs).');
$documents = [];
foreach ($documentIds as $docId) {
$doc = $this->entityManager->getEntityById('Document', $docId);
if (!$doc) throw new NotFound("Document {$docId} not found.");
$documents[] = $doc;
}
$mergerService = $this->injectableFactory->create(
\Espo\Modules\NetworkStorageIntegration\Services\DocumentMergerService::class
);
$options = $params['options'] ?? [];
$mergedPath = $mergerService->mergeWithLabels($documents, $options);
return [
'success' => true,
'message' => "" . count($documents) . " מסמכים מוזגו בהצלחה",
'data' => ['path' => $mergedPath, 'documentCount' => count($documents)],
];
}
private function normalizeDateTime(?string $value): ?string
{
if ($value === null || $value === '') return null;
@@ -228,4 +407,23 @@ class ActionExecutor
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
return $bytes . ' B';
}
private function executePluginTool(string $tool, array $params, ?string $caseId, string $userId): array
{
$handlerClass = $this->metadata->get(['app', 'smartAssistant', 'tools', $tool, 'handler']);
if (!$handlerClass) {
throw new Error("Unknown tool: {$tool}");
}
$this->log->debug("SmartAssistant: Executing plugin tool={$tool} handler={$handlerClass}");
$handler = $this->injectableFactory->create($handlerClass);
if (!method_exists($handler, 'execute')) {
throw new Error("Plugin tool handler {$handlerClass} must have an execute() method.");
}
return $handler->execute($params, $caseId, $userId);
}
}
@@ -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');
@@ -0,0 +1,41 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
class AssistantRuleContextProvider
{
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->entityManager = $entityManager;
$this->log = $log;
}
public function getRulesForMode(string $mode): array
{
$entries = $this->entityManager->getRDBRepository('AssistantRule')
->where([
'isActive' => true,
'deleted' => false,
'scope' => ['global', $mode],
])
->order('createdAt', 'ASC')
->find();
$rules = [];
foreach ($entries as $entry) {
$rules[] = [
'name' => $entry->get('name'),
'rule' => $entry->get('rule'),
'scope' => $entry->get('scope'),
];
}
return $rules;
}
}
@@ -29,6 +29,7 @@ class CaseContextBuilder
'contacts' => $this->getCaseContacts($case),
'openTasks' => $this->getOpenTasks($caseId),
'upcomingMeetings' => $this->getUpcomingMeetings($caseId),
'recentCalls' => $this->getRecentCalls($caseId),
'recentNotes' => $this->getRecentNotes($caseId),
'availableTemplates' => $this->getAvailableTemplates(),
'documents' => $this->getDocumentListing($caseId),
@@ -113,6 +114,24 @@ class CaseContextBuilder
return $meetings;
}
private function getRecentCalls(string $caseId): array
{
$calls = [];
$collection = $this->entityManager->getRDBRepository('Call')
->where(['parentType' => 'Case', 'parentId' => $caseId])
->order('dateStart', 'DESC')->limit(0, 10)->find();
foreach ($collection as $c) {
$calls[] = [
'id' => $c->get('id'), 'name' => $c->get('name'),
'status' => $c->get('status'), 'direction' => $c->get('direction'),
'dateStart' => $c->get('dateStart'), 'dateEnd' => $c->get('dateEnd'),
'description' => $c->get('description'),
];
}
return $calls;
}
private function getRecentNotes(string $caseId): array
{
$notes = [];
@@ -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; }
}
@@ -6,8 +6,8 @@ use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageFactory;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageInterface;
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
class DocumentAnalyzer
{
@@ -43,17 +43,19 @@ class DocumentAnalyzer
$casePath = $this->getCaseFolderPath($caseId);
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
$storage = $this->getStorage();
$topLevel = $storage->listFolder($casePath);
$client = $this->getStorageClient();
$topLevel = $client->listFolder($casePath);
$folders = []; $rootFiles = []; $totalFiles = 0;
foreach ($topLevel as $item) {
if ($item['isFolder']) {
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
if ($isFolder) {
$subFiles = [];
try {
foreach ($storage->listFolder($item['path']) as $sub) {
if (!$sub['isFolder']) {
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'], 'mimeType' => $sub['mimeType'], 'modified' => $sub['modified'] ?? null];
foreach ($client->listFolder($item['path']) as $sub) {
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
if (!$subIsFolder) {
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null];
$totalFiles++;
}
}
@@ -62,7 +64,7 @@ class DocumentAnalyzer
}
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
} else {
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'], 'mimeType' => $item['mimeType'], 'modified' => $item['modified'] ?? null];
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'] ?? null, 'mimeType' => $item['mimeType'] ?? null, 'modified' => $item['modified'] ?? null];
$totalFiles++;
}
}
@@ -72,17 +74,12 @@ class DocumentAnalyzer
public function extractTextContent(string $filePath): ?string
{
$storage = $this->getStorage();
$parentPath = dirname($filePath);
$client = $this->getStorageClient();
$filename = basename($filePath);
try { $items = $storage->listFolder($parentPath); } catch (\Exception $e) { return null; }
try { $content = $client->downloadFile($filePath); } catch (\Exception $e) { return null; }
$fileSize = null;
foreach ($items as $item) { if ($item['name'] === $filename) { $fileSize = $item['size']; break; } }
if ($fileSize !== null && $fileSize > self::MAX_FILE_SIZE) return null;
try { $content = $storage->downloadFile($filePath); } catch (\Exception $e) { return null; }
if (strlen($content) > self::MAX_FILE_SIZE) return null;
if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
@@ -107,7 +104,7 @@ class DocumentAnalyzer
public function renameDocument(string $sourcePath, string $newName): array
{
$storage = $this->getStorage();
$client = $this->getStorageClient();
$parentPath = dirname($sourcePath);
$oldName = basename($sourcePath);
@@ -120,21 +117,66 @@ class DocumentAnalyzer
$destination = $parentPath . '/' . $newName;
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
$storage->move($sourcePath, $destination);
$client->move($sourcePath, $destination);
$this->updateDocumentEntity($sourcePath, $destination);
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
}
private function getCaseFolderPath(string $caseId): ?string
public function uploadDocument(string $caseId, string $fileName, string $content, ?string $subfolder = null): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
return $case ? ($case->get('nextCloudFolderPath') ?: null) : null;
$casePath = $this->getCaseFolderPath($caseId);
if (!$casePath) {
throw new Error('Case does not have a document folder configured (networkStorageFolderPath).');
}
$client = $this->getStorageClient();
$targetDir = $casePath;
if ($subfolder) {
$targetDir = $casePath . '/' . trim($subfolder, '/');
if (!$client->exists($targetDir)) {
$client->createFolderRecursive($targetDir);
}
}
$targetPath = $targetDir . '/' . $fileName;
$client->uploadFile($targetPath, $content);
return [
'path' => $targetPath,
'fileName' => $fileName,
'folder' => $subfolder,
];
}
private function getStorage(): FileStorageInterface
public function getCaseFolderPath(string $caseId): ?string
{
return $this->injectableFactory->create(FileStorageFactory::class)->create();
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return null;
// Try stored path first
$storedPath = $case->get('networkStorageFolderPath') ?: null;
if ($storedPath) return $storedPath;
// Fall back to computed path from NetworkDocumentService
try {
$nds = $this->getNetworkDocumentService();
return $nds->getEntityFolderPath('Case', $caseId);
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
return null;
}
}
private function getStorageClient(): StorageClientInterface
{
return $this->getNetworkDocumentService()->getClient();
}
private function getNetworkDocumentService(): NetworkDocumentService
{
return $this->injectableFactory->create(NetworkDocumentService::class);
}
private function extractFromPdf(string $tmpFile): ?string
@@ -168,10 +210,10 @@ class DocumentAnalyzer
private function updateDocumentEntity(string $oldPath, string $newPath): void
{
try {
$doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne();
$doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
if ($doc) {
$doc->set('nextCloudPath', $newPath);
$doc->set('nextCloudFilename', basename($newPath));
$doc->set('networkStoragePath', $newPath);
$doc->set('networkStorageFileName', basename($newPath));
$this->entityManager->saveEntity($doc);
}
} catch (\Exception $e) {}
@@ -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', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
@@ -70,6 +71,10 @@ class SmartAssistantService
}
}
// Load behavioral rules
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
// Generate conversation ID
if (!$conversationId) {
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
@@ -103,27 +108,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 +168,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 +179,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 +258,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),
];
}
@@ -343,12 +378,19 @@ class SmartAssistantService
throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.');
}
$siteUrl = $this->config->get('siteUrl') ?? '';
$payload = json_encode([
'message' => $message,
'context' => $context,
'conversationId' => $conversationId,
'conversationHistory' => $history,
'mode' => $mode,
'systemInstructions' => $this->getSystemInstructions(),
'espocrm' => [
'url' => rtrim($siteUrl, '/'),
'apiKey' => $this->getEspocrmApiKey(),
],
]);
$headers = ['Content-Type: application/json', 'Accept: application/json'];
@@ -406,14 +448,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,30 +460,58 @@ 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;
}
private function getEspocrmApiKey(): ?string
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->espocrmApiKey)) {
return $data->espocrmApiKey;
}
}
return null;
}
private function getSystemInstructions(): array
{
return [
'call_vs_meeting' => implode(' ', [
'כשהמשתמש מדווח ששוחח, דיבר, התקשר, או שוחחה עם לקוח/ה — השתמש בכלי create_call (לא create_meeting).',
'כשהמשתמש מדווח שנפגש, קבע פגישה, או תיאם מפגש — השתמש בכלי create_meeting.',
'שיחת טלפון = create_call. פגישה פיזית/זום = create_meeting.',
]),
'open_questions' => implode(' ', [
'אם שאלת את המשתמש שאלה והוא לא ענה עליה בתגובה הבאה — חזור על השאלה בתחילת התשובה הבאה.',
'עקוב אחרי שאלות פתוחות ואל תוותר עליהן עד שהמשתמש עונה או אומר לך לוותר.',
]),
'behavioral_rules' => implode(' ', [
'אם context.assistantRules מכיל כללים — יש ליישם אותם בכל שיחה.',
'כללים גלובליים תקפים תמיד. כללי "case" תקפים רק במצב תיק. כללי "office" תקפים רק במצב משרד.',
'אם המשתמש מבקש להגדיר כלל חדש (למשל "תמיד תעשה X כש-Y") — השתמש בכלי save_rule כדי לשמור אותו.',
]),
];
}
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.4.0",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-03-29",
"releaseDate": "2026-04-06",
"php": [
">=8.1"
]