Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c97ba2361 | |||
| 94397c809b | |||
| b810c9fcff | |||
| 0dc26f3b15 | |||
| ca04dd72f8 | |||
| 87bc4b6770 | |||
| 2fe807b7f9 | |||
| 026c32a3f7 | |||
| 2e350e33b9 | |||
| 1d7eb19a43 | |||
| 0fc53b54b7 | |||
| 4c0bf3a67c | |||
| 35c57d36ae |
@@ -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 (שיפור עתידי)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -24,7 +24,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
||||
afterRender: function () {
|
||||
this._alerts = [];
|
||||
this._summary = {};
|
||||
this._activeType = null;
|
||||
this._activeType = 'attention';
|
||||
|
||||
var self = this;
|
||||
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
|
||||
@@ -54,10 +54,10 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
||||
|
||||
renderCards: function (summary) {
|
||||
var cards = [
|
||||
{type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
|
||||
{type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
|
||||
{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'},
|
||||
{type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
|
||||
];
|
||||
|
||||
var self = this;
|
||||
|
||||
@@ -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;">' +
|
||||
@@ -21,6 +24,9 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
' <button class="btn btn-link sa-new-btn" type="button" title="{{translate \'New Conversation\' scope=\'SmartAssistant\'}}">' +
|
||||
' <span class="fas fa-plus"></span>' +
|
||||
' </button>' +
|
||||
' <button class="btn btn-link sa-expand-btn" type="button" title="{{translate \'Expand\' scope=\'SmartAssistant\'}}">' +
|
||||
' <span class="fas fa-expand-alt"></span>' +
|
||||
' </button>' +
|
||||
' <button class="btn btn-link sa-close-btn" type="button">' +
|
||||
' <span class="fas fa-times"></span>' +
|
||||
' </button>' +
|
||||
@@ -50,7 +56,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,10 +65,15 @@ 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-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; }' +
|
||||
'.sa-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; transition: width 0.3s ease, max-height 0.3s ease; }' +
|
||||
'.sa-chat-panel.sa-expanded { width: 700px; max-height: 85vh; }' +
|
||||
'.sa-chat-panel.sa-expanded .sa-chat-messages { max-height: calc(85vh - 180px); }' +
|
||||
'.sa-chat-panel.sa-expanded .sa-history-list, .sa-chat-panel.sa-expanded .sa-memory-list { max-height: calc(85vh - 200px); }' +
|
||||
'@keyframes sa-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }' +
|
||||
'.sa-chat-header { display: flex; align-items: center; padding: 10px 16px; background: #5c6bc0; color: #fff; gap: 8px; }' +
|
||||
'.sa-chat-title { font-size: 15px; font-weight: 600; white-space: nowrap; }' +
|
||||
@@ -76,14 +87,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; }' +
|
||||
@@ -111,7 +128,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
'.sa-badge-source { background: #f3e5f5; color: #7b1fa2; }' +
|
||||
'.sa-memory-entry-content { font-size: 12px; color: #555; line-height: 1.4; max-height: 60px; overflow: hidden; }' +
|
||||
'.sa-memory-entry-meta { font-size: 10px; color: #999; margin-top: 4px; }' +
|
||||
'@media (max-width: 480px) { .sa-chat-panel { width: calc(100vw - 48px); left: 0; } .smart-assistant-fab-container { bottom: 16px; left: 16px; } }' +
|
||||
'@media (max-width: 480px) { .sa-chat-panel { width: calc(100vw - 48px); left: 0; } .sa-chat-panel.sa-expanded { width: calc(100vw - 48px); } .smart-assistant-fab-container { bottom: 16px; left: 16px; } }' +
|
||||
'</style>',
|
||||
|
||||
conversationId: null,
|
||||
@@ -120,12 +137,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
currentCaseId: null,
|
||||
currentCaseName: null,
|
||||
currentView: 'chat',
|
||||
isExpanded: false,
|
||||
currentMemoryCategory: null,
|
||||
memoryData: null,
|
||||
|
||||
events: {
|
||||
'click .sa-fab': function () { this.togglePanel(); },
|
||||
'click .sa-close-btn': function () { this.closePanel(); },
|
||||
'click .sa-expand-btn': function () { this.toggleExpand(); },
|
||||
'click .sa-chat-send': function () { this.sendMessage(); },
|
||||
'click .sa-history-btn': function () { this.showHistory(); },
|
||||
'click .sa-new-btn': function () { this.startNewConversation(); },
|
||||
@@ -141,11 +160,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);
|
||||
@@ -246,7 +268,26 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
|
||||
closePanel: function () {
|
||||
this.isOpen = false;
|
||||
this.$el.find('.sa-chat-panel').css('display', 'none');
|
||||
this.isExpanded = false;
|
||||
var $panel = this.$el.find('.sa-chat-panel');
|
||||
$panel.removeClass('sa-expanded').css('display', 'none');
|
||||
this.$el.find('.sa-expand-btn .fas').removeClass('fa-compress-alt').addClass('fa-expand-alt');
|
||||
},
|
||||
|
||||
toggleExpand: function () {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
var $panel = this.$el.find('.sa-chat-panel');
|
||||
var $icon = this.$el.find('.sa-expand-btn .fas');
|
||||
|
||||
if (this.isExpanded) {
|
||||
$panel.addClass('sa-expanded');
|
||||
$icon.removeClass('fa-expand-alt').addClass('fa-compress-alt');
|
||||
} else {
|
||||
$panel.removeClass('sa-expanded');
|
||||
$icon.removeClass('fa-compress-alt').addClass('fa-expand-alt');
|
||||
}
|
||||
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
showChat: function () {
|
||||
@@ -422,7 +463,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
this.scrollToBottom();
|
||||
|
||||
$btn.prop('disabled', true);
|
||||
$input.prop('disabled', true).val('');
|
||||
$input.prop('disabled', true).val('').css('height', 'auto');
|
||||
|
||||
var payload = {
|
||||
message: message,
|
||||
@@ -439,19 +480,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -611,11 +645,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, '• $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(/^[-*] (.+)/, '• $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) {
|
||||
@@ -644,7 +768,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
recordView.model.trigger('update-all');
|
||||
}
|
||||
|
||||
if (entityType === 'Task' || entityType === 'Meeting') {
|
||||
if (entityType === 'Task' || entityType === 'Meeting' || entityType === 'Call') {
|
||||
var sideView = currentView.getView('side');
|
||||
|
||||
if (sideView) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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": "ימים לאזהרה קריטית",
|
||||
|
||||
+56
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -8,6 +8,10 @@
|
||||
"type": "varchar",
|
||||
"maxLength": 255
|
||||
},
|
||||
"espocrmApiKey": {
|
||||
"type": "varchar",
|
||||
"maxLength": 255
|
||||
},
|
||||
"maxMessagesPerHour": {
|
||||
"type": "int",
|
||||
"default": 30
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"rows": [
|
||||
[{"name": "name"}, {"name": "scope"}],
|
||||
[{"name": "isActive"}, false],
|
||||
[{"name": "rule", "fullWidth": true}],
|
||||
[{"name": "createdAt"}, {"name": "createdBy"}]
|
||||
]
|
||||
}
|
||||
]
|
||||
+7
@@ -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,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
|
||||
}
|
||||
@@ -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,21 @@ 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),
|
||||
'delete_meeting' => $this->deleteMeeting($params),
|
||||
'delete_call' => $this->deleteCall($params),
|
||||
'delete_task' => $this->deleteTask($params),
|
||||
'delete_note' => $this->deleteNote($params),
|
||||
'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 +86,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 +179,137 @@ 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 deleteMeeting(array $params): array
|
||||
{
|
||||
$entityId = $params['entityId'] ?? null;
|
||||
if (!$entityId) {
|
||||
throw new Error('entityId parameter is required.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById('Meeting', $entityId);
|
||||
if (!$entity) {
|
||||
throw new NotFound("Meeting {$entityId} not found.");
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ פגישה \"{$name}\" נמחקה בהצלחה",
|
||||
'entityType' => 'Meeting',
|
||||
'entityId' => $entityId,
|
||||
];
|
||||
}
|
||||
|
||||
private function deleteCall(array $params): array
|
||||
{
|
||||
$entityId = $params['entityId'] ?? null;
|
||||
if (!$entityId) {
|
||||
throw new Error('entityId parameter is required.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById('Call', $entityId);
|
||||
if (!$entity) {
|
||||
throw new NotFound("Call {$entityId} not found.");
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ שיחה \"{$name}\" נמחקה בהצלחה",
|
||||
'entityType' => 'Call',
|
||||
'entityId' => $entityId,
|
||||
];
|
||||
}
|
||||
|
||||
private function deleteNote(array $params): array
|
||||
{
|
||||
$entityId = $params['entityId'] ?? null;
|
||||
if (!$entityId) {
|
||||
throw new Error('entityId parameter is required.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById('Note', $entityId);
|
||||
if (!$entity) {
|
||||
throw new NotFound("Note {$entityId} not found.");
|
||||
}
|
||||
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '✅ הערה נמחקה בהצלחה',
|
||||
'entityType' => 'Note',
|
||||
'entityId' => $entityId,
|
||||
];
|
||||
}
|
||||
|
||||
private function deleteTask(array $params): array
|
||||
{
|
||||
$entityId = $params['entityId'] ?? null;
|
||||
if (!$entityId) {
|
||||
throw new Error('entityId parameter is required.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById('Task', $entityId);
|
||||
if (!$entity) {
|
||||
throw new NotFound("Task {$entityId} not found.");
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ משימה \"{$name}\" נמחקה בהצלחה",
|
||||
'entityType' => 'Task',
|
||||
'entityId' => $entityId,
|
||||
];
|
||||
}
|
||||
|
||||
private function scheduleHearing(array $params, ?string $caseId, string $userId): array
|
||||
@@ -214,6 +395,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 +502,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -356,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'];
|
||||
@@ -441,6 +470,38 @@ class SmartAssistantService
|
||||
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
|
||||
{
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
"module": "SmartAssistant",
|
||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||
"author": "klear",
|
||||
"version": "2.0.0",
|
||||
"version": "2.5.2",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"releaseDate": "2026-04-04",
|
||||
"releaseDate": "2026-04-06",
|
||||
"php": [
|
||||
">=8.1"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user