Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c6e54d233 | |||
| 6c97ba2361 | |||
| 94397c809b | |||
| b810c9fcff | |||
| 0dc26f3b15 | |||
| ca04dd72f8 | |||
| 87bc4b6770 | |||
| 2fe807b7f9 | |||
| 026c32a3f7 | |||
| 2e350e33b9 | |||
| 1d7eb19a43 | |||
| 0fc53b54b7 | |||
| 4c0bf3a67c | |||
| 35c57d36ae | |||
| 0f77631f75 | |||
| 81fa5f3a08 | |||
| cf0e9ad2bc | |||
| 07df19b412 | |||
| b9542e3980 | |||
| 4f5b6be884 | |||
| b8e8c01da5 | |||
| 62004360e8 | |||
| f616065585 | |||
| 1eb856b178 | |||
| c9d08aa1b9 | |||
| 233f56eccf | |||
| 0157b31249 | |||
| 49c42e3d20 | |||
| 4dad65f578 | |||
| f116611c73 | |||
| ff2388e7e1 |
@@ -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 (שיפור עתידי)
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
# SmartAssistant v1.0.3 — סיכום מנהלים לפריסה בפיתוח
|
||||||
|
|
||||||
|
**תאריך:** 2026-03-31
|
||||||
|
**גרסה:** 1.0.3 | **תאריך שחרור:** 2026-03-29
|
||||||
|
**סביבת יעד:** EspoCRM Development (https://espocrm.dev.marcus-law.co.il)
|
||||||
|
**מחבר:** klear | **דרישות:** EspoCRM >= 8.0.0, PHP >= 8.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. תיאור כללי
|
||||||
|
|
||||||
|
SmartAssistant הוא מודול עוזר AI מאוחד למשרד עורכי דין.
|
||||||
|
|
||||||
|
המודול מספק:
|
||||||
|
- **ממשק צ'אט צף** (Floating Action Button) בכל דפי המערכת
|
||||||
|
- **מצב משרד** — סקירה כללית, התראות, סטטיסטיקות עומס עבודה
|
||||||
|
- **מצב תיק** — סיוע מעמיק בתיק ספציפי עם הקשר מלא
|
||||||
|
- **מערכת זיכרון תיק** (CaseMemory) — מאגר ידע מובנה לכל תיק ב-7 קטגוריות
|
||||||
|
- **ביצוע פעולות באישור** — יצירת משימות, פתקים, דיונים, שינוי סטטוס
|
||||||
|
- **אינטגרציה עם Stream** — כל אינטראקציה מופיעה בציר הזמן של התיק
|
||||||
|
- **התראות בסרגל ניווט** — תיקים לא פעילים, משימות באיחור, דיונים קרובים
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. מה בוצע
|
||||||
|
|
||||||
|
### 2.1 Backend (PHP) — 16 קבצים
|
||||||
|
|
||||||
|
| רכיב | קובץ | תפקיד |
|
||||||
|
|-------|------|--------|
|
||||||
|
| Controller | `Controllers/SmartAssistant.php` | 10 נקודות קצה API (chat, execute, summary, alerts, history, conversations, conversationMessages, searchHistory, caseMemory, saveMemory) |
|
||||||
|
| שירות ראשי | `Services/SmartAssistantService.php` | תזמור מלא: בניית הקשר → קריאת webhook → טיפול בפעולות → שמירת שיחה |
|
||||||
|
| בונה הקשר תיק | `Services/CaseContextBuilder.php` | אוסף: נתוני תיק, אנשי קשר, משימות פתוחות, דיונים קרובים, פתקים אחרונים, זיכרון |
|
||||||
|
| בונה הקשר משרד | `Services/OfficeContextBuilder.php` | סטטיסטיקות כלליות, התראות, עומס עבודה לפי משתמש, drill-down לתיקים |
|
||||||
|
| מחשבון התראות | `Services/AlertCalculator.php` | 6 סוגי התראות: תיקים לא פעילים (14/30 יום), משימות באיחור, דיונים קרובים, תיקים ללא שיוך, משימות בעדיפות גבוהה |
|
||||||
|
| מבצע פעולות | `Services/ActionExecutor.php` | 8 כלים: create_task, add_note, change_status, create_meeting, schedule_hearing, list_documents, analyze_document, save_memory, rename_document |
|
||||||
|
| שירות זיכרון | `Services/CaseMemoryService.php` | CRUD מלא לאנטיטי CaseMemory |
|
||||||
|
| ספק הקשר זיכרון | `Services/CaseMemoryContextProvider.php` | הזרקת זיכרון מוצמד/חשוב להקשר AI |
|
||||||
|
| מאגר שיחות | `Services/ConversationRepository.php` | שמירה ואחזור שיחות ב-AssistantConversation |
|
||||||
|
| מנתח מסמכים | `Services/DocumentAnalyzer.php` | סריקת מסמכים דרך FileStorage |
|
||||||
|
| Hook - שינוי תיק | `Hooks/Case/CaseFieldChangeMemory.php` | זיכרון אוטומטי בשינוי: סטטוס, שופט, בית משפט, מועד דיון |
|
||||||
|
| Hook - דיון | `Hooks/Meeting/HearingScheduledMemory.php` | זיכרון אוטומטי בקביעת דיון חדש |
|
||||||
|
| FileStorage | `Classes/FileStorage/*` | 4 קבצים: Interface, Factory, WebDav, NextCloud |
|
||||||
|
|
||||||
|
### 2.2 Frontend (JavaScript) — 9 קבצים
|
||||||
|
|
||||||
|
| רכיב | תפקיד |
|
||||||
|
|-------|--------|
|
||||||
|
| `floating-chat.js` | ממשק צ'אט צף (~1500 שורות): שליחת הודעות, היסטוריה, זיכרון, אישור/דחיית פעולות, עיצוב RTL, Material Design |
|
||||||
|
| `site/navbar.js` | אייקון התראות בסרגל ניווט עם badge מספרי |
|
||||||
|
| `dashlets/smart-assistant.js` | דשלט לדשבורד: סיכום מדדים + התראות אחרונות |
|
||||||
|
| `dashlets/options/smart-assistant.js` | הגדרות דשלט |
|
||||||
|
| `case/modals/add-memory.js` | מודל להוספת זיכרון ידנית לתיק |
|
||||||
|
| `stream/notes/smart-request.js` | תצוגת הודעת משתמש ב-Stream |
|
||||||
|
| `stream/notes/smart-response.js` | תצוגת תשובת עוזר ב-Stream |
|
||||||
|
| `stream/notes/smart-action.js` | תצוגת פעולה שבוצעה ב-Stream |
|
||||||
|
|
||||||
|
### 2.3 מטא-נתונים וקונפיגורציה — 17 קבצי JSON
|
||||||
|
|
||||||
|
- **routes.json** — 10 נתיבי API
|
||||||
|
- **module.json** — רישום מודול עם clientModule
|
||||||
|
- **entityDefs/** — CaseMemory (אנטיטי חדש, 15 שדות, 4 אינדקסים), Case (שדה cNextHearing), Note (סוגי stream חדשים)
|
||||||
|
- **scopes/CaseMemory.json** — הגדרות scope
|
||||||
|
- **layouts/CaseMemory/** — 4 layouts (list, detail, listSmall, detailSmall)
|
||||||
|
- **dashlets/SmartAssistant.json** — הגדרת דשלט
|
||||||
|
- **integrations/SmartAssistant.json** — 6 שדות הגדרה (webhookUrl, apiKey, maxMessagesPerHour, inactivityWarningDays, inactivityCriticalDays, upcomingHearingDays)
|
||||||
|
- **clientDefs/App.json** — רישום navbar
|
||||||
|
|
||||||
|
### 2.4 תרגומים — 10 קבצים
|
||||||
|
|
||||||
|
- **en_US** — 5 קבצי תרגום (SmartAssistant, CaseMemory, Case, Global, Integration)
|
||||||
|
- **fa_IR** — 5 קבצי תרגום מקבילים (עברית/פרסית)
|
||||||
|
|
||||||
|
### 2.5 בנייה והפצה
|
||||||
|
|
||||||
|
- **build.sh** — סקריפט בנייה: קורא גרסה מ-manifest.json, מעדכן README, יוצר ZIP
|
||||||
|
- **composer.json** — הגדרת חבילה ל-Composer Registry
|
||||||
|
- **4 גרסאות ZIP** — 1.0.0, 1.0.1, 1.0.2, 1.0.3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. היסטוריית גרסאות ותיקוני באגים
|
||||||
|
|
||||||
|
### v1.0.0 — `a148e0f`
|
||||||
|
- **פיצ'ר ראשוני** — מודול SmartAssistant מלא עם case memory
|
||||||
|
|
||||||
|
### v1.0.0 → v1.0.1 — תיקונים
|
||||||
|
| Commit | תקלה | תיקון |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| `0cdbf6d` | דשלט ירש מ-DashletView שלא קיים בגרסאות חדשות | שונה ל-base View + הוספת getTitle() |
|
||||||
|
| `ffd966a` | לא היה composer.json לפרסום אוטומטי | נוסף composer.json |
|
||||||
|
| `bd1cd17` | גרסה ב-README לא מתעדכנת אוטומטית | build.sh מעדכן README |
|
||||||
|
| `95ca8a0` | חסר תיעוד מקיף | נוסף README מלא בעברית |
|
||||||
|
| `fbe4f82` | נתיבי client לא תואמים לפורמט מודולים של EspoCRM | מיגרציה לנתיבי מודול תקניים |
|
||||||
|
|
||||||
|
### v1.0.1 → v1.0.2 — `f380542`
|
||||||
|
| תקלה | תיקון |
|
||||||
|
|-------|--------|
|
||||||
|
| נתיבי CSS ו-script שגויים ב-app/client.json metadata | תיקון הנתיבים ל-format הנכון |
|
||||||
|
|
||||||
|
### v1.0.2 → v1.0.3 — `d5c8a60`
|
||||||
|
| תקלה | תיקון |
|
||||||
|
|-------|--------|
|
||||||
|
| EspoCRM לא מצליח לרזולב templates בצד לקוח כי חסר clientModule | הוספת `clientModule` ל-module.json |
|
||||||
|
|
||||||
|
### סיכום תקלות שנמצאו ותוקנו
|
||||||
|
|
||||||
|
| # | תקלה | חומרה | סטטוס |
|
||||||
|
|---|-------|--------|--------|
|
||||||
|
| 1 | דשלט ירש מ-class לא קיים | גבוהה | תוקן v1.0.1 |
|
||||||
|
| 2 | נתיבי client לא תקניים לפורמט מודולים | גבוהה | תוקן v1.0.1 |
|
||||||
|
| 3 | נתיבי CSS/script שגויים ב-metadata | בינונית | תוקן v1.0.2 |
|
||||||
|
| 4 | חסר clientModule לרזולוציית templates | גבוהה | תוקן v1.0.3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. מה נבדק
|
||||||
|
|
||||||
|
### נבדק ידנית (על סמך תיקוני הבאגים)
|
||||||
|
- רישום מודול וטעינת client-side assets
|
||||||
|
- תצוגת דשלט בדשבורד
|
||||||
|
- רזולוציית templates בצד לקוח
|
||||||
|
- נתיבי metadata (CSS, scripts)
|
||||||
|
|
||||||
|
### לא נבדק (אין קבצי טסט בפרויקט)
|
||||||
|
- **אין בדיקות יחידה (unit tests)** לקוד PHP
|
||||||
|
- **אין בדיקות אינטגרציה** ל-API endpoints
|
||||||
|
- **אין בדיקות E2E** לממשק הצ'אט
|
||||||
|
- **אין CI/CD pipeline** (אין GitHub Actions / Gitea Actions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. דרישות קדם לפריסה בפיתוח
|
||||||
|
|
||||||
|
| דרישה | סטטוס | פירוט |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| EspoCRM >= 8.0.0 | לאמת | לוודא גרסה בסביבת dev |
|
||||||
|
| PHP >= 8.1 | לאמת | לוודא גרסה בשרת |
|
||||||
|
| Webhook URL | **נדרש הגדרה** | יש להגדיר n8n workflow שיקבל את הפניות |
|
||||||
|
| API Key | אופציונלי | לאימות webhook |
|
||||||
|
| AssistantConversation entity | לאמת | נדרש שהאנטיטי קיים |
|
||||||
|
| WebDAV/NextCloud | אופציונלי | רק לפיצ'ר ניתוח מסמכים |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. מה נשאר לעשות
|
||||||
|
|
||||||
|
### קריטי לפני פריסה
|
||||||
|
| # | משימה | עדיפות |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 1 | **הגדרת n8n workflow** לקבלת webhook מהעוזר (chat endpoint) | קריטי |
|
||||||
|
| 2 | **בדיקת תאימות** עם גרסת EspoCRM בסביבת dev | קריטי |
|
||||||
|
| 3 | **לוודא שאין שאריות** של מודולים ישנים בסביבת dev | קריטי |
|
||||||
|
| 4 | **הגדרת Integration** — webhookUrl + apiKey ב-Admin > Integrations | קריטי |
|
||||||
|
| 5 | **בדיקה שאנטיטי AssistantConversation קיים** או שצריך ליצור אותו | קריטי |
|
||||||
|
|
||||||
|
### חשוב אחרי פריסה
|
||||||
|
| # | משימה | עדיפות |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 6 | **בדיקות ידניות מקיפות** — צ'אט במצב משרד ומצב תיק | גבוהה |
|
||||||
|
| 7 | **בדיקת פעולות** — create_task, add_note, schedule_hearing, change_status | גבוהה |
|
||||||
|
| 8 | **בדיקת hooks** — שינוי סטטוס תיק → זיכרון אוטומטי | גבוהה |
|
||||||
|
| 9 | **בדיקת התראות** — navbar badge + דשלט | בינונית |
|
||||||
|
| 10 | **בדיקת היסטוריה** — שמירה ואחזור שיחות, חיפוש | בינונית |
|
||||||
|
|
||||||
|
### שיפורים עתידיים
|
||||||
|
| # | משימה | עדיפות |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 11 | כתיבת בדיקות יחידה ל-PHP | בינונית |
|
||||||
|
| 12 | הוספת CI/CD pipeline (Gitea Actions) | בינונית |
|
||||||
|
| 13 | הוספת תרגום עברי (he_IL) — כרגע en_US + fa_IR בלבד | בינונית |
|
||||||
|
| 14 | Rate limiting בצד client (כרגע רק בצד server) | נמוכה |
|
||||||
|
| 15 | הצפנת שיחות | נמוכה |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. תיעוד מפורט של הקוד והקבצים
|
||||||
|
|
||||||
|
### 7.1 ארכיטקטורה כללית
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (JS) │
|
||||||
|
│ floating-chat.js ←→ navbar.js ←→ dashlet.js │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ [צ'אט צף] [התראות navbar] [דשלט דשבורד] │
|
||||||
|
└───────────────────────┬─────────────────────────┘
|
||||||
|
│ REST API (10 endpoints)
|
||||||
|
┌───────────────────────┴─────────────────────────┐
|
||||||
|
│ Controller/SmartAssistant.php │
|
||||||
|
│ chat | execute | summary | alerts | history ... │
|
||||||
|
└───────────────────────┬─────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────┴─────────────────────────┐
|
||||||
|
│ SmartAssistantService.php │
|
||||||
|
│ תזמור: הקשר → webhook → פעולות → שמירה │
|
||||||
|
├──────────┬──────────┬──────────┬─────────────────┤
|
||||||
|
│ Context │ Action │ Memory │ Conversation │
|
||||||
|
│ Builders │ Executor │ Service │ Repository │
|
||||||
|
├──────────┴──────────┴──────────┴─────────────────┤
|
||||||
|
│ CaseContext │ OfficeContext │ AlertCalculator │
|
||||||
|
│ Builder │ Builder │ │
|
||||||
|
└───────────────┴──────────────┴───────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────┴──────────┐
|
||||||
|
│ External n8n │
|
||||||
|
│ Webhook (AI) │
|
||||||
|
└────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 זרימת צ'אט (Chat Flow)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. משתמש שולח הודעה ← floating-chat.js
|
||||||
|
2. POST /SmartAssistant/action/chat { message, caseId?, conversationId }
|
||||||
|
3. Controller → SmartAssistantService::chat()
|
||||||
|
4. בניית הקשר:
|
||||||
|
├─ מצב תיק → CaseContextBuilder (נתוני תיק + אנשי קשר + משימות + דיונים + זיכרון)
|
||||||
|
└─ מצב משרד → OfficeContextBuilder (סטטיסטיקות + התראות)
|
||||||
|
5. קריאת webhook חיצוני (n8n) עם: message + context + history
|
||||||
|
6. קבלת תשובה: { text, actions[] }
|
||||||
|
7. פעולות מיידיות (list_documents, save_memory) → ביצוע אוטומטי
|
||||||
|
8. פעולות הדורשות אישור → שמירה + החזרה למשתמש
|
||||||
|
9. שמירת שיחה ב-ConversationRepository
|
||||||
|
10. שמירת הודעות ב-Stream (smart-request + smart-response notes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 פירוט קבצי Backend
|
||||||
|
|
||||||
|
#### `Controllers/SmartAssistant.php` (168 שורות)
|
||||||
|
- **actionChat()** — מקבל message, caseId, conversationId. בודק ACL ל-Case. קורא ל-SmartAssistantService::chat()
|
||||||
|
- **actionExecute()** — מקבל actionId, conversationId. מבצע פעולה שאושרה
|
||||||
|
- **actionSummary()** — מחזיר סיכום משרדי + התראות
|
||||||
|
- **actionAlerts()** — רשימת התראות מלאה
|
||||||
|
- **actionHistory()** — היסטוריית שיחות (לפי caseId או אחרונה)
|
||||||
|
- **actionConversations()** — רשימת שיחות אחרונות
|
||||||
|
- **actionConversationMessages()** — הודעות שיחה ספציפית
|
||||||
|
- **actionSearchHistory()** — חיפוש חופשי בשיחות
|
||||||
|
- **actionCaseMemory()** — זיכרון תיק לפי קטגוריה
|
||||||
|
- **actionSaveMemory()** — שמירת זיכרון ידנית
|
||||||
|
|
||||||
|
#### `Services/SmartAssistantService.php` (~500 שורות)
|
||||||
|
הקובץ המרכזי ביותר. אחראי על:
|
||||||
|
- **chat()** — הזרימה המלאה: בניית הקשר, קריאת webhook, עיבוד פעולות, שמירת שיחה
|
||||||
|
- **callWebhook()** — קריאת CURL ל-n8n עם timeout, headers (X-Api-Key), JSON payload
|
||||||
|
- **processActions()** — מיון פעולות: מיידיות vs. דורשות אישור
|
||||||
|
- **executeAction()** — ביצוע פעולה שאושרה דרך ActionExecutor
|
||||||
|
- **getSummary()** / **getAlerts()** — מצב משרד
|
||||||
|
- **getConversations()** / **getHistory()** / **searchHistory()** — ניהול שיחות
|
||||||
|
|
||||||
|
#### `Services/CaseContextBuilder.php` (~150 שורות)
|
||||||
|
בונה הקשר עשיר לתיק:
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'case' => [...], // שדות תיק מרכזיים
|
||||||
|
'contacts' => [...], // אנשי קשר מקושרים
|
||||||
|
'openTasks' => [...], // משימות פתוחות (עד 10)
|
||||||
|
'upcomingMeetings' => [...], // דיונים קרובים (עד 5)
|
||||||
|
'recentNotes' => [...], // פתקים אחרונים (עד 10)
|
||||||
|
'memories' => [...], // זיכרון מוצמד + חשוב
|
||||||
|
'stats' => [...] // מספרי סיכום
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Services/OfficeContextBuilder.php` (~80 שורות)
|
||||||
|
בונה סיכום משרדי:
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'totalActiveCases' => int,
|
||||||
|
'casesByStatus' => [...],
|
||||||
|
'overdueTasks' => int,
|
||||||
|
'upcomingHearings' => int,
|
||||||
|
'userWorkload' => [...], // עומס לפי משתמש
|
||||||
|
'alerts' => [...]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Services/ActionExecutor.php` (~250 שורות)
|
||||||
|
מבצע 8 סוגי פעולות:
|
||||||
|
|
||||||
|
| כלי | פרמטרים | פעולה |
|
||||||
|
|------|----------|--------|
|
||||||
|
| `create_task` | name, dateEnd, priority, description | יצירת Task מקושר לתיק |
|
||||||
|
| `add_note` | text | הוספת Post ב-Stream של התיק |
|
||||||
|
| `change_status` | status | שינוי סטטוס (עם validation מול enum) |
|
||||||
|
| `create_meeting` | name, dateStart, dateEnd, description | יצירת Meeting |
|
||||||
|
| `schedule_hearing` | date, description | עדכון cNextHearing + יצירת Meeting |
|
||||||
|
| `list_documents` | — | רשימת מסמכים (read-only, ללא אישור) |
|
||||||
|
| `analyze_document` | documentId | ניתוח מסמך דרך DocumentAnalyzer |
|
||||||
|
| `save_memory` | name, content, category, importance | שמירת CaseMemory (ללא אישור) |
|
||||||
|
| `rename_document` | documentId, newName | שינוי שם קובץ |
|
||||||
|
|
||||||
|
#### `Services/AlertCalculator.php` (~200 שורות)
|
||||||
|
6 סוגי התראות:
|
||||||
|
|
||||||
|
| סוג | לוגיקה | חומרה |
|
||||||
|
|------|--------|--------|
|
||||||
|
| תיק לא פעיל (אזהרה) | modifiedAt < now - 14 יום | warning |
|
||||||
|
| תיק לא פעיל (קריטי) | modifiedAt < now - 30 יום | critical |
|
||||||
|
| משימה באיחור | dateEnd < now, status != Completed | critical |
|
||||||
|
| דיון קרוב | dateStart < now + 7 ימים | warning |
|
||||||
|
| תיק ללא שיוך | status = New, assignedUserId = null | warning |
|
||||||
|
| משימה בעדיפות גבוהה | priority = Urgent, dateEnd < now + 5 ימים | warning |
|
||||||
|
|
||||||
|
#### `Services/CaseMemoryService.php` (106 שורות)
|
||||||
|
CRUD פשוט:
|
||||||
|
- **getMemories()** — לפי caseId, עם סינון אופציונלי לפי category
|
||||||
|
- **saveMemory()** — יצירת רשומת CaseMemory
|
||||||
|
- **updateMemory()** — עדכון
|
||||||
|
- **deleteMemory()** — מחיקה רכה
|
||||||
|
|
||||||
|
#### `Services/CaseMemoryContextProvider.php`
|
||||||
|
מזריק זיכרון להקשר AI:
|
||||||
|
- זיכרון מוצמד (isPinned = true)
|
||||||
|
- זיכרון בחשיבות high/critical
|
||||||
|
- ממוין לפי importance DESC, modifiedAt DESC
|
||||||
|
|
||||||
|
#### `Services/ConversationRepository.php` (~120 שורות)
|
||||||
|
- **save()** — שמירת שיחה באנטיטי AssistantConversation
|
||||||
|
- **getHistory()** — אחזור לפי caseId או אחרון
|
||||||
|
- **getConversations()** — רשימה ממוינת (עד limit)
|
||||||
|
- **getMessages()** — הודעות שיחה ספציפית
|
||||||
|
- **search()** — חיפוש חופשי בתוכן
|
||||||
|
|
||||||
|
#### Hooks
|
||||||
|
- **CaseFieldChangeMemory** — afterSave hook על Case. בודק אם שדות key השתנו (status, cJudge, cCourt, cNextHearing). אם כן, יוצר CaseMemory אוטומטי מסוג `auto` בקטגוריה המתאימה.
|
||||||
|
- **HearingScheduledMemory** — afterSave hook על Meeting. אם סוג Meeting = "Hearing", יוצר CaseMemory בקטגוריה timeline.
|
||||||
|
|
||||||
|
### 7.4 פירוט קבצי Frontend
|
||||||
|
|
||||||
|
#### `floating-chat.js` (~1500 שורות)
|
||||||
|
הקובץ הגדול ביותר. View יחיד שמנהל:
|
||||||
|
- **תצוגת צ'אט** — קלט הודעה, בועות הודעות (משתמש/עוזר), פעולות בהמתנה
|
||||||
|
- **תצוגת היסטוריה** — רשימת שיחות קודמות, טעינת שיחה
|
||||||
|
- **תצוגת זיכרון** — tabs לפי קטגוריה, הוספה/מחיקה/הצמדה
|
||||||
|
- **RTL layout** — כל ה-CSS מותאם לעברית
|
||||||
|
- **עיצוב Material Design** — צבע ראשי #5c6bc0 (אינדיגו), אנימציות slide-up
|
||||||
|
- **רספונסיבי** — breakpoint ב-480px למובייל
|
||||||
|
- **Inline CSS** — כל הסגנונות מוטמעים בקובץ (לא CSS חיצוני)
|
||||||
|
|
||||||
|
#### `site/navbar.js`
|
||||||
|
- מרחיב את navbar הרגיל של EspoCRM
|
||||||
|
- מוסיף אייקון התראות עם badge מספרי
|
||||||
|
- קורא ל-GET /SmartAssistant/action/alerts ומציג ספירה
|
||||||
|
- רענון אוטומטי כל X דקות
|
||||||
|
|
||||||
|
#### `dashlets/smart-assistant.js`
|
||||||
|
- דשלט לדשבורד הראשי
|
||||||
|
- מציג: סה"כ תיקים פעילים, משימות באיחור, דיונים קרובים
|
||||||
|
- רשימת 5 התראות אחרונות
|
||||||
|
- ACL scope = Case
|
||||||
|
|
||||||
|
#### Stream Notes (3 קבצים)
|
||||||
|
- **smart-request.js** — מציג הודעת משתמש עם אייקון שאלה
|
||||||
|
- **smart-response.js** — מציג תשובת עוזר עם markdown rendering
|
||||||
|
- **smart-action.js** — מציג פעולה שבוצעה עם סטטוס (approved/rejected/executed)
|
||||||
|
|
||||||
|
#### `case/modals/add-memory.js`
|
||||||
|
- מודל Espo.Views.Modal סטנדרטי
|
||||||
|
- שדות: name, category (dropdown), content (textarea), importance (dropdown)
|
||||||
|
- POST ל-/SmartAssistant/action/saveMemory
|
||||||
|
|
||||||
|
### 7.5 אנטיטי CaseMemory — מבנה מלא
|
||||||
|
|
||||||
|
```
|
||||||
|
שדות:
|
||||||
|
├── name (varchar, required) — כותרת הזיכרון
|
||||||
|
├── case (link → Case, required) — תיק משויך
|
||||||
|
├── category (enum) — key_facts | strategy | decisions | contacts_notes | timeline | documents_notes | billing_notes
|
||||||
|
├── content (text, required) — תוכן מלא
|
||||||
|
├── source (enum, readOnly) — manual | assistant | auto
|
||||||
|
├── importance (enum) — low | normal | high | critical
|
||||||
|
├── isPinned (bool) — הצמדה לגישה מהירה
|
||||||
|
├── isArchived (bool) — הסתרת רשומות ישנות
|
||||||
|
├── sourceEntityType (varchar, readOnly) — סוג מקור (למעקב)
|
||||||
|
├── sourceEntityId (varchar, readOnly) — ID מקור
|
||||||
|
├── assignedUser (link → User)
|
||||||
|
├── teams (linkMultiple → Team)
|
||||||
|
├── createdAt, modifiedAt (datetime)
|
||||||
|
└── createdBy, modifiedBy (link → User)
|
||||||
|
|
||||||
|
אינדקסים:
|
||||||
|
├── caseId + deleted
|
||||||
|
├── caseId + category + deleted
|
||||||
|
├── caseId + isPinned + deleted
|
||||||
|
└── importance + deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 הגדרות אינטגרציה
|
||||||
|
|
||||||
|
**Admin > Integrations > Smart Assistant:**
|
||||||
|
|
||||||
|
| שדה | סוג | ברירת מחדל | תיאור |
|
||||||
|
|------|------|------------|--------|
|
||||||
|
| webhookUrl | url | — | כתובת webhook (n8n) |
|
||||||
|
| apiKey | varchar(255) | — | מפתח אימות, נשלח כ-X-Api-Key header |
|
||||||
|
| maxMessagesPerHour | int | 30 | הגבלת קצב |
|
||||||
|
| inactivityWarningDays | int | 14 | סף אזהרה לחוסר פעילות |
|
||||||
|
| inactivityCriticalDays | int | 30 | סף קריטי |
|
||||||
|
| upcomingHearingDays | int | 7 | חלון דיונים קרובים |
|
||||||
|
|
||||||
|
### 7.7 פורמט Webhook
|
||||||
|
|
||||||
|
**Request (נשלח ל-n8n):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "מה המצב של תיק כהן?",
|
||||||
|
"context": {
|
||||||
|
"case": { "name": "כהן נ' לוי", "status": "Active", ... },
|
||||||
|
"contacts": [...],
|
||||||
|
"openTasks": [...],
|
||||||
|
"memories": [...]
|
||||||
|
},
|
||||||
|
"conversationId": "conv_abc123",
|
||||||
|
"conversationHistory": [
|
||||||
|
{ "role": "user", "content": "..." },
|
||||||
|
{ "role": "assistant", "content": "..." }
|
||||||
|
],
|
||||||
|
"mode": "case"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (נדרש מ-n8n):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "תיק כהן פעיל. יש 3 משימות פתוחות ודיון ב-15 באפריל.",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"tool": "create_task",
|
||||||
|
"params": { "name": "הכנת סיכומים לדיון", "dateEnd": "2026-04-14" },
|
||||||
|
"actionId": "action_xyz789",
|
||||||
|
"displayText": "יצירת משימה: הכנת סיכומים לדיון"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. סיכום מוכנות לפריסה
|
||||||
|
|
||||||
|
| קריטריון | סטטוס | הערות |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| קוד backend | מוכן | 16 קבצי PHP, ארכיטקטורה נקייה |
|
||||||
|
| קוד frontend | מוכן | 9 קבצי JS, עיצוב RTL |
|
||||||
|
| מטא-נתונים | מוכן | תוקן ב-v1.0.1-1.0.3 |
|
||||||
|
| תרגומים | חלקי | en_US + fa_IR, חסר he_IL ייעודי |
|
||||||
|
| קובץ התקנה | מוכן | SmartAssistant-1.0.3.zip |
|
||||||
|
| n8n workflow | **חסר** | צריך לבנות workflow שיטפל ב-chat |
|
||||||
|
| בדיקות אוטומטיות | **אין** | אין unit/integration/E2E tests |
|
||||||
|
| CI/CD | **אין** | אין pipeline |
|
||||||
|
| תיעוד | מוכן | README מקיף בעברית |
|
||||||
|
|
||||||
|
**המלצה:** המודול מוכן טכנית לפריסה בסביבת פיתוח. יש להגדיר webhook URL (n8n workflow) לפני שהצ'אט יהיה פונקציונלי. מומלץ לבצע בדיקות ידניות מקיפות לאחר ההתקנה.
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
# SmartAssistant - עוזר חכם
|
# SmartAssistant - עוזר חכם
|
||||||
|
|
||||||
**גרסה:** 1.0.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
**גרסה:** 2.0.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||||
|
|
||||||
## תיאור
|
## תיאור
|
||||||
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
||||||
|
|
||||||
מחליף את CrmAssistant + OfficeAssistant (deprecated).
|
|
||||||
|
|
||||||
## תלויות
|
## תלויות
|
||||||
- Webhook חיצוני (n8n או דומה) לעיבוד AI
|
- Webhook חיצוני (n8n או דומה) לעיבוד AI
|
||||||
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
|
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
|
|||||||
|
יצירת טבלה מעוצבת עם תמיכה מלאה בעברית ואנגלית מעורבת.
|
||||||
|
|
||||||
|
כאשר המשתמש מבקש טבלה בכל הקשר — תכנית עבודה, סיכום, השוואה, רשימה — השתמש בפונקציה `bidi_table()` מ-`scripts/bidi_table.py`.
|
||||||
|
|
||||||
|
## הוראות
|
||||||
|
|
||||||
|
1. **תמיד** השתמש ב-Bash כדי להריץ את הסקריפט — אל תנסה לייצר טבלת box-drawing ידנית כי ה-BiDi ישבור אותה.
|
||||||
|
|
||||||
|
2. הרץ כך:
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import sys; sys.path.insert(0, '/home/chaim/legal-ai')
|
||||||
|
from scripts.bidi_table import bidi_table
|
||||||
|
print(bidi_table(
|
||||||
|
['Header1', 'Header2', 'Header3'],
|
||||||
|
[
|
||||||
|
['value1', 'ערך בעברית', 'mixed ערבוב'],
|
||||||
|
['value2', 'ערך נוסף', 'עוד שורה'],
|
||||||
|
],
|
||||||
|
))
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. כותרות עמודות — עדיף באנגלית (כי שורת הכותרת הכי רגישה ל-BiDi).
|
||||||
|
|
||||||
|
4. תוכן בעברית, באנגלית, או מעורב — הכל עובד בגוף הטבלה.
|
||||||
|
|
||||||
|
5. אם המשתמש מבקש טבלה כחלק ממסמך MD שנכתב לקובץ (לא לטרמינל) — אפשר להשתמש ב-markdown רגיל כי קוראי MD מטפלים ב-RTL בעצמם.
|
||||||
|
|
||||||
|
## $ARGUMENTS
|
||||||
|
תוכן הטבלה — כותרות ושורות. אם לא צוין, שאל את המשתמש מה להציג.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""BiDi-safe box-drawing table renderer for mixed Hebrew/English terminal output.
|
||||||
|
|
||||||
|
Uses LRM (Left-to-Right Mark, U+200E) before box-drawing characters to prevent
|
||||||
|
the BiDi algorithm from breaking table alignment when Hebrew text is present.
|
||||||
|
|
||||||
|
Usage as module:
|
||||||
|
from scripts.bidi_table import bidi_table
|
||||||
|
print(bidi_table(['Col1', 'Col2'], [['val1', 'ערך2']]))
|
||||||
|
|
||||||
|
Usage from CLI:
|
||||||
|
python3 scripts/bidi_table.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
LRM = "\u200E" # Left-to-Right Mark — invisible, prevents BiDi reordering
|
||||||
|
|
||||||
|
|
||||||
|
def bidi_table(headers: list[str], rows: list[list[str]]) -> str:
|
||||||
|
"""Render a box-drawing table safe for mixed RTL/LTR terminal display."""
|
||||||
|
ncols = len(headers)
|
||||||
|
|
||||||
|
# Calculate column widths
|
||||||
|
col_widths = [len(h) for h in headers]
|
||||||
|
for row in rows:
|
||||||
|
for i, cell in enumerate(row[:ncols]):
|
||||||
|
col_widths[i] = max(col_widths[i], len(cell))
|
||||||
|
|
||||||
|
def hline(left: str, mid: str, right: str) -> str:
|
||||||
|
return left + mid.join("─" * (w + 2) for w in col_widths) + right
|
||||||
|
|
||||||
|
def dataline(cells: list[str]) -> str:
|
||||||
|
parts = []
|
||||||
|
for i in range(ncols):
|
||||||
|
cell = cells[i] if i < len(cells) else ""
|
||||||
|
padded = cell + " " * max(0, col_widths[i] - len(cell))
|
||||||
|
parts.append(" " + padded + " ")
|
||||||
|
return LRM + "│" + (LRM + "│").join(parts) + LRM + "│"
|
||||||
|
|
||||||
|
lines = [hline("┌", "┬", "┐")]
|
||||||
|
lines.append(dataline(headers))
|
||||||
|
lines.append(hline("├", "┼", "┤"))
|
||||||
|
for row in rows:
|
||||||
|
lines.append(dataline(row))
|
||||||
|
lines.append(hline("└", "┴", "┘"))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
table = bidi_table(
|
||||||
|
["File", "Description", "Model", "Step"],
|
||||||
|
[
|
||||||
|
["claims_extractor.py", "חילוץ טענות מכתבי טענות", "Sonnet", "שלב 3 — הבא בתור"],
|
||||||
|
["brainstorm.py", "סיעור מוחות — כיווני נימוק", "Sonnet", "שלב 4"],
|
||||||
|
["block_writer.py", "כתיבת בלוקים של החלטה", "Sonnet/Opus", "שלב 5"],
|
||||||
|
["qa_validator.py", "בדיקת איכות QA", "Sonnet", "שלב 6"],
|
||||||
|
["style_analyzer.py", "ניתוח סגנון דפנה", "Opus", "חד-פעמי"],
|
||||||
|
["learning_loop.py", "למידה מהחלטה סופית", "Sonnet", "סוף תהליך"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
print(table)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -5,7 +5,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
|||||||
name: 'SmartAssistant',
|
name: 'SmartAssistant',
|
||||||
|
|
||||||
getTitle: function () {
|
getTitle: function () {
|
||||||
return this.translate('Smart Assistant', 'labels', 'SmartAssistant') || 'Smart Assistant';
|
return 'שירה — עוזרת אישית';
|
||||||
},
|
},
|
||||||
|
|
||||||
templateContent: '' +
|
templateContent: '' +
|
||||||
@@ -24,8 +24,20 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
|||||||
afterRender: function () {
|
afterRender: function () {
|
||||||
this._alerts = [];
|
this._alerts = [];
|
||||||
this._summary = {};
|
this._summary = {};
|
||||||
this._activeType = null;
|
this._activeType = 'attention';
|
||||||
this.loadSummary();
|
|
||||||
|
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 > Integrations > Smart Assistant</div>'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.loadSummary();
|
||||||
|
}).catch(function () {
|
||||||
|
self.loadSummary();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
loadSummary: function () {
|
loadSummary: function () {
|
||||||
@@ -33,6 +45,8 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
|||||||
Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) {
|
Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) {
|
||||||
self._summary = response.summary || {};
|
self._summary = response.summary || {};
|
||||||
self._alerts = response.alerts || [];
|
self._alerts = response.alerts || [];
|
||||||
|
self._openCases = response.openCases || [];
|
||||||
|
self._upcomingHearings = response.upcomingHearings || [];
|
||||||
self.renderCards(self._summary);
|
self.renderCards(self._summary);
|
||||||
self.showDetail(self._activeType);
|
self.showDetail(self._activeType);
|
||||||
}).catch(function () {});
|
}).catch(function () {});
|
||||||
@@ -40,10 +54,10 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
|||||||
|
|
||||||
renderCards: function (summary) {
|
renderCards: function (summary) {
|
||||||
var cards = [
|
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: '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: '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;
|
var self = this;
|
||||||
@@ -62,9 +76,11 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
|||||||
},
|
},
|
||||||
|
|
||||||
showDetail: function (type) {
|
showDetail: function (type) {
|
||||||
|
var self = this;
|
||||||
var $detail = this.$el.find('.sa-dashlet-detail');
|
var $detail = this.$el.find('.sa-dashlet-detail');
|
||||||
|
|
||||||
if (!type || type === this._activeType && $detail.html()) {
|
if (!type || type === this._activeType && $detail.html()) {
|
||||||
|
// Toggle off
|
||||||
this._activeType = null;
|
this._activeType = null;
|
||||||
$detail.html('');
|
$detail.html('');
|
||||||
this.renderCards(this._summary);
|
this.renderCards(this._summary);
|
||||||
@@ -80,11 +96,11 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
|||||||
if (type === 'overdue') {
|
if (type === 'overdue') {
|
||||||
items = alerts.filter(function (a) { return a.type === 'overdue_task'; });
|
items = alerts.filter(function (a) { return a.type === 'overdue_task'; });
|
||||||
} else if (type === 'hearings') {
|
} else if (type === 'hearings') {
|
||||||
items = alerts.filter(function (a) { return a.type === 'hearing_no_prep'; });
|
items = this._upcomingHearings || [];
|
||||||
} else if (type === 'attention') {
|
} else if (type === 'attention') {
|
||||||
items = alerts.filter(function (a) { return a.severity === 'critical' || a.severity === 'warning'; });
|
items = alerts.filter(function (a) { return a.severity === 'critical' || a.severity === 'warning'; });
|
||||||
} else if (type === 'open') {
|
} else if (type === 'open') {
|
||||||
items = alerts.filter(function (a) { return a.type === 'inactive_case' || a.type === 'unassigned_case'; });
|
items = this._openCases || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -104,7 +120,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
|||||||
|
|
||||||
html += '<div style="padding: 6px 8px; border-bottom: 1px solid #f0f0f0; font-size: 12px; color: ' + color + ';">' +
|
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 class="fas ' + icon + '" style="margin-left: 6px;"></span>' +
|
||||||
link + Espo.Utils.escapeString(a.message) + linkEnd +
|
link + self.escapeString(a.message) + linkEnd +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
return View.extend({
|
return View.extend({
|
||||||
|
|
||||||
templateContent: '' +
|
templateContent: '' +
|
||||||
'<div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
|
'<div class="sa-fab-wrapper">' +
|
||||||
' <span class="fas fa-robot"></span>' +
|
' <div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
|
||||||
|
' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" class="sa-fab-avatar" alt="שירה">' +
|
||||||
|
' </div>' +
|
||||||
' <span class="sa-fab-badge" style="display:none;"></span>' +
|
' <span class="sa-fab-badge" style="display:none;"></span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="sa-chat-panel" style="display:none;">' +
|
'<div class="sa-chat-panel" style="display:none;">' +
|
||||||
' <div class="sa-chat-header">' +
|
' <div class="sa-chat-header">' +
|
||||||
' <span class="sa-chat-title">{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}</span>' +
|
' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" style="width: 26px; height: 26px; border-radius: 50%; margin-left: 8px;">' +
|
||||||
|
' <span class="sa-chat-title">שירה</span>' +
|
||||||
' <span class="sa-mode-badge"></span>' +
|
' <span class="sa-mode-badge"></span>' +
|
||||||
' <div class="sa-chat-header-actions">' +
|
' <div class="sa-chat-header-actions">' +
|
||||||
' <button class="btn btn-link sa-memory-btn" type="button" title="{{translate \'Case Memory\' scope=\'SmartAssistant\'}}" style="display:none;">' +
|
' <button class="btn btn-link sa-memory-btn" type="button" title="{{translate \'Case Memory\' scope=\'SmartAssistant\'}}" style="display:none;">' +
|
||||||
@@ -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\'}}">' +
|
' <button class="btn btn-link sa-new-btn" type="button" title="{{translate \'New Conversation\' scope=\'SmartAssistant\'}}">' +
|
||||||
' <span class="fas fa-plus"></span>' +
|
' <span class="fas fa-plus"></span>' +
|
||||||
' </button>' +
|
' </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">' +
|
' <button class="btn btn-link sa-close-btn" type="button">' +
|
||||||
' <span class="fas fa-times"></span>' +
|
' <span class="fas fa-times"></span>' +
|
||||||
' </button>' +
|
' </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-summary" style="display:none;"></div>' +
|
||||||
' <div class="sa-chat-messages"></div>' +
|
' <div class="sa-chat-messages"></div>' +
|
||||||
' <div class="sa-chat-input-row">' +
|
' <div class="sa-chat-input-row">' +
|
||||||
' <input type="text" class="form-control sa-chat-input" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}">' +
|
' <textarea class="form-control sa-chat-input" rows="1" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}"></textarea>' +
|
||||||
' <button class="btn btn-primary sa-chat-send" type="button" disabled>' +
|
' <button class="btn btn-primary sa-chat-send" type="button" disabled>' +
|
||||||
' <span class="fas fa-paper-plane"></span>' +
|
' <span class="fas fa-paper-plane"></span>' +
|
||||||
' </button>' +
|
' </button>' +
|
||||||
@@ -59,10 +65,15 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<style>' +
|
'<style>' +
|
||||||
'.smart-assistant-fab-container { position: fixed; bottom: 24px; left: 24px; z-index: 1050; direction: rtl; font-family: inherit; }' +
|
'.smart-assistant-fab-container { position: fixed; bottom: 24px; left: 24px; z-index: 1050; direction: rtl; font-family: inherit; }' +
|
||||||
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: #5c6bc0; color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s, background 0.2s; font-size: 22px; position: relative; }' +
|
'.sa-fab-wrapper { position: relative; display: inline-block; }' +
|
||||||
'.sa-fab:hover { background: #3f51b5; transform: scale(1.08); }' +
|
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: transparent; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s; overflow: hidden; }' +
|
||||||
'.sa-fab-badge { position: absolute; top: -4px; right: -4px; background: #c62828; color: #fff; border-radius: 10px; min-width: 20px; height: 20px; font-size: 11px; font-weight: bold; display: flex; align-items: center; justify-content: center; padding: 0 5px; }' +
|
'.sa-fab:hover { transform: scale(1.08); }' +
|
||||||
'.sa-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-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); } }' +
|
'@keyframes sa-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }' +
|
||||||
'.sa-chat-header { display: flex; align-items: center; padding: 10px 16px; background: #5c6bc0; color: #fff; gap: 8px; }' +
|
'.sa-chat-header { display: flex; align-items: center; padding: 10px 16px; background: #5c6bc0; color: #fff; gap: 8px; }' +
|
||||||
'.sa-chat-title { font-size: 15px; font-weight: 600; white-space: nowrap; }' +
|
'.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-chat-messages:empty::before { content: attr(data-empty-text); display: block; text-align: center; color: #999; padding: 30px 10px; font-size: 13px; }' +
|
||||||
'.sa-msg { margin-bottom: 10px; padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; max-width: 90%; word-wrap: break-word; }' +
|
'.sa-msg { margin-bottom: 10px; padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; max-width: 90%; word-wrap: break-word; }' +
|
||||||
'.sa-msg-user { background: #e3f2fd; margin-left: auto; margin-right: 0; text-align: right; }' +
|
'.sa-msg-user { background: #e3f2fd; margin-left: auto; margin-right: 0; text-align: right; }' +
|
||||||
'.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; }' +
|
'.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; position: relative; padding-right: 36px; }' +
|
||||||
|
'.sa-msg-assistant::before { content: ""; position: absolute; right: 6px; top: 6px; width: 24px; height: 24px; border-radius: 50%; background: url(client/custom/modules/smart-assistant/img/shira-avatar.png) center/cover; }' +
|
||||||
'.sa-msg-error { background: #fce4ec; color: #c62828; }' +
|
'.sa-msg-error { background: #fce4ec; color: #c62828; }' +
|
||||||
'.sa-msg-loading { background: #f5f5f5; color: #999; }' +
|
'.sa-msg-loading { background: #f5f5f5; color: #999; }' +
|
||||||
|
'.sa-table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; direction: rtl; text-align: right; }' +
|
||||||
|
'.sa-table th, .sa-table td { border: 1px solid #ddd; padding: 5px 8px; white-space: nowrap; unicode-bidi: plaintext; }' +
|
||||||
|
'.sa-table th { background: #f0eef8; font-weight: 600; color: #333; }' +
|
||||||
|
'.sa-table tr:nth-child(even) { background: #fafafa; }' +
|
||||||
|
'.sa-table tr:hover { background: #f5f0ff; }' +
|
||||||
'.sa-msg-action { background: #fff8e1; padding: 6px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }' +
|
'.sa-msg-action { background: #fff8e1; padding: 6px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }' +
|
||||||
'.sa-action-btns { display: flex; gap: 4px; }' +
|
'.sa-action-btns { display: flex; gap: 4px; }' +
|
||||||
'.sa-action-btns .btn { padding: 2px 8px; font-size: 11px; border-radius: 4px; }' +
|
'.sa-action-btns .btn { padding: 2px 8px; font-size: 11px; border-radius: 4px; }' +
|
||||||
'.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; }' +
|
'.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; align-items: flex-end; }' +
|
||||||
'.sa-chat-input { flex: 1; border-radius: 20px; padding: 8px 14px; font-size: 13px; direction: rtl; }' +
|
'.sa-chat-input { flex: 1; border-radius: 16px; padding: 8px 14px; font-size: 13px; direction: rtl; resize: none; overflow-y: auto; max-height: 120px; min-height: 36px; line-height: 1.4; font-family: inherit; }' +
|
||||||
'.sa-chat-send { border-radius: 50%; width: 36px; height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; }' +
|
'.sa-chat-send { border-radius: 50%; width: 36px; height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; }' +
|
||||||
'.sa-history-view, .sa-memory-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }' +
|
'.sa-history-view, .sa-memory-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }' +
|
||||||
'.sa-history-header, .sa-memory-header { padding: 8px 14px; border-bottom: 1px solid #eee; direction: rtl; text-align: right; display: flex; justify-content: space-between; align-items: center; }' +
|
'.sa-history-header, .sa-memory-header { padding: 8px 14px; border-bottom: 1px solid #eee; direction: rtl; text-align: right; display: flex; justify-content: space-between; align-items: center; }' +
|
||||||
@@ -111,7 +128,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
'.sa-badge-source { background: #f3e5f5; color: #7b1fa2; }' +
|
'.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-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; }' +
|
'.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>',
|
'</style>',
|
||||||
|
|
||||||
conversationId: null,
|
conversationId: null,
|
||||||
@@ -120,12 +137,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
currentCaseId: null,
|
currentCaseId: null,
|
||||||
currentCaseName: null,
|
currentCaseName: null,
|
||||||
currentView: 'chat',
|
currentView: 'chat',
|
||||||
|
isExpanded: false,
|
||||||
currentMemoryCategory: null,
|
currentMemoryCategory: null,
|
||||||
memoryData: null,
|
memoryData: null,
|
||||||
|
|
||||||
events: {
|
events: {
|
||||||
'click .sa-fab': function () { this.togglePanel(); },
|
'click .sa-fab': function () { this.togglePanel(); },
|
||||||
'click .sa-close-btn': function () { this.closePanel(); },
|
'click .sa-close-btn': function () { this.closePanel(); },
|
||||||
|
'click .sa-expand-btn': function () { this.toggleExpand(); },
|
||||||
'click .sa-chat-send': function () { this.sendMessage(); },
|
'click .sa-chat-send': function () { this.sendMessage(); },
|
||||||
'click .sa-history-btn': function () { this.showHistory(); },
|
'click .sa-history-btn': function () { this.showHistory(); },
|
||||||
'click .sa-new-btn': function () { this.startNewConversation(); },
|
'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');
|
var cat = $(e.currentTarget).data('category');
|
||||||
this.filterMemory(cat);
|
this.filterMemory(cat);
|
||||||
},
|
},
|
||||||
'keypress .sa-chat-input': function (e) {
|
'keydown .sa-chat-input': function (e) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); }
|
||||||
},
|
},
|
||||||
'input .sa-chat-input': function (e) {
|
'input .sa-chat-input': function (e) {
|
||||||
this.$el.find('.sa-chat-send').prop('disabled', !e.target.value.trim());
|
this.$el.find('.sa-chat-send').prop('disabled', !e.target.value.trim());
|
||||||
|
// Auto-grow textarea
|
||||||
|
e.target.style.height = 'auto';
|
||||||
|
e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px';
|
||||||
},
|
},
|
||||||
'click .sa-action-approve': function (e) {
|
'click .sa-action-approve': function (e) {
|
||||||
var $btn = $(e.currentTarget);
|
var $btn = $(e.currentTarget);
|
||||||
@@ -158,10 +180,22 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
},
|
},
|
||||||
|
|
||||||
afterRender: function () {
|
afterRender: function () {
|
||||||
this.$el.find('.sa-chat-messages').attr('data-empty-text',
|
var self = this;
|
||||||
this.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם העוזר החכם');
|
|
||||||
this.loadAlertCount();
|
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
|
||||||
this._setupRouteListener();
|
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 () {
|
_setupRouteListener: function () {
|
||||||
@@ -234,7 +268,26 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
|
|
||||||
closePanel: function () {
|
closePanel: function () {
|
||||||
this.isOpen = false;
|
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 () {
|
showChat: function () {
|
||||||
@@ -405,12 +458,12 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
|
|
||||||
$messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
|
$messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
|
||||||
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
|
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
|
||||||
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושב...') + '</div>');
|
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...') + '</div>');
|
||||||
$messages.append($loading);
|
$messages.append($loading);
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
|
|
||||||
$btn.prop('disabled', true);
|
$btn.prop('disabled', true);
|
||||||
$input.prop('disabled', true).val('');
|
$input.prop('disabled', true).val('').css('height', 'auto');
|
||||||
|
|
||||||
var payload = {
|
var payload = {
|
||||||
message: message,
|
message: message,
|
||||||
@@ -421,25 +474,18 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
payload.caseId = this.currentCaseId;
|
payload.caseId = this.currentCaseId;
|
||||||
}
|
}
|
||||||
|
|
||||||
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload).then(function (response) {
|
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 180000}).then(function (response) {
|
||||||
self.conversationId = response.conversationId;
|
self.conversationId = response.conversationId;
|
||||||
$loading.remove();
|
$loading.remove();
|
||||||
|
|
||||||
$messages.append('<div class="sa-msg sa-msg-assistant">' + self.formatResponse(response.text) + '</div>');
|
$messages.append('<div class="sa-msg sa-msg-assistant">' + self.formatResponse(response.text) + '</div>');
|
||||||
|
|
||||||
// Render action items
|
// Auto-refresh panels if actions were executed
|
||||||
if (response.actions && response.actions.length > 0) {
|
if (response.executedActions && response.executedActions.length > 0) {
|
||||||
response.actions.forEach(function (action) {
|
response.executedActions.forEach(function (action) {
|
||||||
var actionHtml = '<div class="sa-msg-action">' +
|
if (action.entityType) {
|
||||||
'<span><span class="fas fa-bolt" style="color: #f57c00; margin-left: 4px;"></span>' +
|
self.refreshCurrentView(action.entityType);
|
||||||
self.escapeHtml(action.displayText || action.tool) + '</span>' +
|
}
|
||||||
'<div class="sa-action-btns">' +
|
|
||||||
'<button class="btn btn-success btn-xs sa-action-approve" data-conversation-id="' + self.escapeHtml(response.conversationId) +
|
|
||||||
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-check"></span></button>' +
|
|
||||||
'<button class="btn btn-danger btn-xs sa-action-reject" data-conversation-id="' + self.escapeHtml(response.conversationId) +
|
|
||||||
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-times"></span></button>' +
|
|
||||||
'</div></div>';
|
|
||||||
$messages.append(actionHtml);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +514,10 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
var cls = approved ? (result.success ? 'sa-msg-assistant' : 'sa-msg-error') : 'sa-msg-loading';
|
var cls = approved ? (result.success ? 'sa-msg-assistant' : 'sa-msg-error') : 'sa-msg-loading';
|
||||||
$messages.append('<div class="sa-msg ' + cls + '">' + self.escapeHtml(msg) + '</div>');
|
$messages.append('<div class="sa-msg ' + cls + '">' + self.escapeHtml(msg) + '</div>');
|
||||||
self.scrollToBottom();
|
self.scrollToBottom();
|
||||||
|
|
||||||
|
if (approved && result.success) {
|
||||||
|
self.refreshCurrentView(result.entityType);
|
||||||
|
}
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
$messages.append('<div class="sa-msg sa-msg-error">שגיאה בביצוע הפעולה</div>');
|
$messages.append('<div class="sa-msg sa-msg-error">שגיאה בביצוע הפעולה</div>');
|
||||||
self.scrollToBottom();
|
self.scrollToBottom();
|
||||||
@@ -595,11 +645,101 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
|
|
||||||
formatResponse: function (text) {
|
formatResponse: function (text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
text = this.escapeHtml(text);
|
|
||||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
var self = this;
|
||||||
text = text.replace(/\n/g, '<br>');
|
var LRM = '\u200E';
|
||||||
text = text.replace(/^- (.+)/gm, '• $1');
|
|
||||||
return text;
|
// Split into lines for table detection
|
||||||
|
var lines = text.split('\n');
|
||||||
|
var result = [];
|
||||||
|
var i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
// Detect markdown table: line with |, followed by separator |---|, followed by more | lines
|
||||||
|
if (lines[i].indexOf('|') !== -1 && i + 1 < lines.length && /^\|?[\s\-:|]+\|/.test(lines[i + 1])) {
|
||||||
|
var tableLines = [];
|
||||||
|
var j = i;
|
||||||
|
|
||||||
|
// Collect all contiguous lines that contain |
|
||||||
|
while (j < lines.length && lines[j].indexOf('|') !== -1) {
|
||||||
|
tableLines.push(lines[j]);
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableLines.length >= 2) {
|
||||||
|
result.push(self.renderMarkdownTable(tableLines, LRM));
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular line — escape and format
|
||||||
|
var line = self.escapeHtml(lines[i]);
|
||||||
|
line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
line = line.replace(/^### (.+)/, '<strong style="font-size: 14px;">$1</strong>');
|
||||||
|
line = line.replace(/^## (.+)/, '<strong style="font-size: 15px;">$1</strong>');
|
||||||
|
|
||||||
|
// List items
|
||||||
|
line = line.replace(/^[-*] (.+)/, '• $1');
|
||||||
|
|
||||||
|
// Numbered lists
|
||||||
|
line = line.replace(/^(\d+)\. (.+)/, '$1. $2');
|
||||||
|
|
||||||
|
result.push(line);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('<br>');
|
||||||
|
},
|
||||||
|
|
||||||
|
renderMarkdownTable: function (lines, LRM) {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
var parseRow = function (line) {
|
||||||
|
// Remove leading/trailing |, split by |
|
||||||
|
var trimmed = line.replace(/^\|/, '').replace(/\|$/, '');
|
||||||
|
return trimmed.split('|').map(function (cell) { return cell.trim(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
// First line = headers
|
||||||
|
var headers = parseRow(lines[0]);
|
||||||
|
|
||||||
|
// Skip separator line (line[1])
|
||||||
|
// Remaining lines = data rows
|
||||||
|
var rows = [];
|
||||||
|
for (var i = 2; i < lines.length; i++) {
|
||||||
|
// Skip if it looks like another separator
|
||||||
|
if (/^[\s\-:|]+$/.test(lines[i].replace(/\|/g, ''))) continue;
|
||||||
|
rows.push(parseRow(lines[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HTML table with LRM for BiDi safety
|
||||||
|
var html = '<table class="sa-table" dir="rtl">';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
html += '<thead><tr>';
|
||||||
|
headers.forEach(function (h) {
|
||||||
|
var escaped = self.escapeHtml(h).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html += '<th>' + LRM + escaped + LRM + '</th>';
|
||||||
|
});
|
||||||
|
html += '</tr></thead>';
|
||||||
|
|
||||||
|
// Body
|
||||||
|
html += '<tbody>';
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
html += '<tr>';
|
||||||
|
for (var c = 0; c < headers.length; c++) {
|
||||||
|
var cell = row[c] !== undefined ? row[c] : '';
|
||||||
|
var escaped = self.escapeHtml(cell).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html += '<td>' + LRM + escaped + LRM + '</td>';
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
|
||||||
|
return html;
|
||||||
},
|
},
|
||||||
|
|
||||||
escapeHtml: function (text) {
|
escapeHtml: function (text) {
|
||||||
@@ -608,5 +748,37 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshCurrentView: function (entityType) {
|
||||||
|
var mainView = this.getParentView();
|
||||||
|
|
||||||
|
while (mainView && mainView.getParentView()) {
|
||||||
|
mainView = mainView.getParentView();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainView) return;
|
||||||
|
|
||||||
|
var currentView = mainView.getView('main');
|
||||||
|
|
||||||
|
if (!currentView) return;
|
||||||
|
|
||||||
|
var recordView = currentView.getView('record');
|
||||||
|
|
||||||
|
if (recordView && recordView.model) {
|
||||||
|
recordView.model.trigger('update-all');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === 'Task' || entityType === 'Meeting' || entityType === 'Call') {
|
||||||
|
var sideView = currentView.getView('side');
|
||||||
|
|
||||||
|
if (sideView) {
|
||||||
|
var activities = sideView.getView('activities');
|
||||||
|
var tasks = sideView.getView('tasks');
|
||||||
|
|
||||||
|
if (activities && activities.actionRefresh) activities.actionRefresh();
|
||||||
|
if (tasks && tasks.actionRefresh) tasks.actionRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
|
|||||||
templateContent: '' +
|
templateContent: '' +
|
||||||
'<div class="sa-action-note sa-action-{{status}}" style="padding: 6px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
|
'<div class="sa-action-note sa-action-{{status}}" style="padding: 6px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
|
||||||
'<span class="fas {{icon}}" style="margin-left: 6px;"></span>' +
|
'<span class="fas {{icon}}" style="margin-left: 6px;"></span>' +
|
||||||
'{{post}}' +
|
'{{{formattedPost}}}' +
|
||||||
'</div>',
|
'</div>',
|
||||||
|
|
||||||
data: function () {
|
data: function () {
|
||||||
@@ -13,6 +13,8 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
|
|||||||
var noteData = this.model.get('data') || {};
|
var noteData = this.model.get('data') || {};
|
||||||
var status = noteData.status || 'executed';
|
var status = noteData.status || 'executed';
|
||||||
|
|
||||||
|
data.post = this.model.get('post');
|
||||||
|
data.formattedPost = this.getHelper().escapeString(data.post || '');
|
||||||
data.status = status;
|
data.status = status;
|
||||||
|
|
||||||
if (status === 'executed') {
|
if (status === 'executed') {
|
||||||
|
|||||||
+14
-3
@@ -5,11 +5,22 @@ define('modules/smart-assistant/views/stream/notes/smart-request', ['views/strea
|
|||||||
templateContent: '' +
|
templateContent: '' +
|
||||||
'<div style="background: #e3f2fd; padding: 8px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
|
'<div style="background: #e3f2fd; padding: 8px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
|
||||||
'<span class="fas fa-user" style="color: #1565c0; margin-left: 6px;"></span>' +
|
'<span class="fas fa-user" style="color: #1565c0; margin-left: 6px;"></span>' +
|
||||||
'{{post}}' +
|
'{{{formattedPost}}}' +
|
||||||
'</div>',
|
'</div>',
|
||||||
|
|
||||||
setup: function () {
|
data: function () {
|
||||||
NoteView.prototype.setup.call(this);
|
var data = NoteView.prototype.data.call(this);
|
||||||
|
|
||||||
|
data.formattedPost = this.formatPost(this.model.get('post'));
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
formatPost: function (text) {
|
||||||
|
if (!text) return '';
|
||||||
|
text = this.getHelper().escapeString(text);
|
||||||
|
text = text.replace(/\n/g, '<br>');
|
||||||
|
return text;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
public function postActionChat(Request $request, Response $response): array
|
||||||
{
|
{
|
||||||
$this->checkAccess();
|
$this->checkAccess();
|
||||||
|
|||||||
@@ -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": {
|
"scopeNames": {
|
||||||
"CaseMemory": "Case Memory",
|
"CaseMemory": "Case Memory",
|
||||||
|
"AssistantRule": "Assistant Rule",
|
||||||
"SmartAssistant": "Smart Assistant"
|
"SmartAssistant": "Smart Assistant"
|
||||||
},
|
},
|
||||||
"scopeNamesPlural": {
|
"scopeNamesPlural": {
|
||||||
"CaseMemory": "Case Memories",
|
"CaseMemory": "Case Memories",
|
||||||
|
"AssistantRule": "Assistant Rules",
|
||||||
"SmartAssistant": "Smart Assistant"
|
"SmartAssistant": "Smart Assistant"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"webhookUrl": "Webhook URL",
|
"webhookUrl": "Webhook URL",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
|
"espocrmApiKey": "EspoCRM API Key",
|
||||||
"maxMessagesPerHour": "Max Messages Per Hour",
|
"maxMessagesPerHour": "Max Messages Per Hour",
|
||||||
"inactivityWarningDays": "Inactivity Warning Days",
|
"inactivityWarningDays": "Inactivity Warning Days",
|
||||||
"inactivityCriticalDays": "Inactivity Critical 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": {
|
"scopeNames": {
|
||||||
"CaseMemory": "זיכרון תיק",
|
"CaseMemory": "זיכרון תיק",
|
||||||
|
"AssistantRule": "כלל התנהגות",
|
||||||
"SmartAssistant": "עוזר חכם"
|
"SmartAssistant": "עוזר חכם"
|
||||||
},
|
},
|
||||||
"scopeNamesPlural": {
|
"scopeNamesPlural": {
|
||||||
"CaseMemory": "זיכרונות תיקים",
|
"CaseMemory": "זיכרונות תיקים",
|
||||||
|
"AssistantRule": "כללי התנהגות",
|
||||||
"SmartAssistant": "עוזר חכם"
|
"SmartAssistant": "עוזר חכם"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"webhookUrl": "כתובת Webhook",
|
"webhookUrl": "כתובת Webhook",
|
||||||
"apiKey": "מפתח API",
|
"apiKey": "מפתח API",
|
||||||
|
"espocrmApiKey": "מפתח API של EspoCRM",
|
||||||
"maxMessagesPerHour": "מקסימום הודעות לשעה",
|
"maxMessagesPerHour": "מקסימום הודעות לשעה",
|
||||||
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
|
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
|
||||||
"inactivityCriticalDays": "ימים לאזהרה קריטית",
|
"inactivityCriticalDays": "ימים לאזהרה קריטית",
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"labels": {
|
"labels": {
|
||||||
"Smart Assistant": "עוזר חכם",
|
"Smart Assistant": "שירה",
|
||||||
"Office Mode": "מצב משרד",
|
"Office Mode": "מצב משרד",
|
||||||
"Case Mode": "מצב תיק",
|
"Case Mode": "מצב תיק",
|
||||||
"Add Memory": "הוסף זיכרון",
|
"Add Memory": "הוספת זיכרון",
|
||||||
"Search Memory": "חפש בזיכרון",
|
"Search Memory": "חיפוש בזיכרון",
|
||||||
"New Conversation": "שיחה חדשה",
|
"New Conversation": "שיחה חדשה",
|
||||||
"Conversation History": "היסטוריית שיחות",
|
"Conversation History": "היסטוריית שיחות",
|
||||||
"Previous Conversations": "שיחות קודמות",
|
"Previous Conversations": "שיחות קודמות",
|
||||||
"No previous conversations": "אין שיחות קודמות",
|
"No previous conversations": "אין שיחות קודמות",
|
||||||
"Ask the assistant...": "שאל/י את העוזר...",
|
"Ask the assistant...": "שאל/י את שירה...",
|
||||||
"Thinking": "חושב...",
|
"Thinking": "חושבת...",
|
||||||
"Error": "שגיאה בתקשורת",
|
"Error": "שגיאה בתקשורת",
|
||||||
"Back": "חזרה",
|
"Back": "חזרה",
|
||||||
"Chat with the assistant": "שוחח/י עם העוזר החכם",
|
"Chat with the assistant": "שוחח/י עם שירה",
|
||||||
"Case Memory": "זיכרון תיק",
|
"Case Memory": "זיכרון תיק",
|
||||||
"Pin": "הצמד",
|
"Pin": "הצמד",
|
||||||
"Unpin": "הסר הצמדה",
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+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",
|
"type": "varchar",
|
||||||
"maxLength": 255
|
"maxLength": 255
|
||||||
},
|
},
|
||||||
|
"espocrmApiKey": {
|
||||||
|
"type": "varchar",
|
||||||
|
"maxLength": 255
|
||||||
|
},
|
||||||
"maxMessagesPerHour": {
|
"maxMessagesPerHour": {
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"default": 30
|
"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
|
||||||
|
}
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"route": "/SmartAssistant/action/status",
|
||||||
|
"method": "get",
|
||||||
|
"params": {
|
||||||
|
"controller": "SmartAssistant",
|
||||||
|
"action": "status"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"route": "/SmartAssistant/action/chat",
|
"route": "/SmartAssistant/action/chat",
|
||||||
"method": "post",
|
"method": "post",
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ use Espo\Core\Exceptions\NotFound;
|
|||||||
use Espo\Core\InjectableFactory;
|
use Espo\Core\InjectableFactory;
|
||||||
use Espo\ORM\EntityManager;
|
use Espo\ORM\EntityManager;
|
||||||
use Espo\Core\Utils\Log;
|
use Espo\Core\Utils\Log;
|
||||||
|
use Espo\Core\Utils\Metadata;
|
||||||
|
|
||||||
class ActionExecutor
|
class ActionExecutor
|
||||||
{
|
{
|
||||||
private EntityManager $entityManager;
|
private EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
private Log $log;
|
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->entityManager = $entityManager;
|
||||||
$this->injectableFactory = $injectableFactory;
|
$this->injectableFactory = $injectableFactory;
|
||||||
$this->log = $log;
|
$this->log = $log;
|
||||||
|
$this->metadata = $metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
public const VALID_STATUSES = [
|
public const VALID_STATUSES = [
|
||||||
@@ -38,12 +41,21 @@ class ActionExecutor
|
|||||||
'add_note' => $this->addNote($params, $caseId, $userId),
|
'add_note' => $this->addNote($params, $caseId, $userId),
|
||||||
'change_status' => $this->changeStatus($params, $caseId),
|
'change_status' => $this->changeStatus($params, $caseId),
|
||||||
'create_meeting' => $this->createMeeting($params, $caseId, $userId),
|
'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),
|
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
|
||||||
'list_documents' => $this->listDocuments($caseId),
|
'list_documents' => $this->listDocuments($caseId),
|
||||||
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
||||||
'rename_document' => $this->renameDocument($params),
|
'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),
|
'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
|
private function createTask(array $params, ?string $caseId, string $userId): array
|
||||||
{
|
{
|
||||||
$task = $this->entityManager->getNewEntity('Task');
|
$taskData = [
|
||||||
$task->set([
|
|
||||||
'name' => $params['name'] ?? 'משימה חדשה',
|
'name' => $params['name'] ?? 'משימה חדשה',
|
||||||
'status' => 'Not Started',
|
'status' => 'Not Started',
|
||||||
'priority' => $params['priority'] ?? 'Normal',
|
'priority' => $params['priority'] ?? 'Normal',
|
||||||
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
|
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
|
||||||
'description' => $params['description'] ?? null,
|
'description' => $params['description'] ?? null,
|
||||||
'parentType' => 'Case', 'parentId' => $caseId,
|
|
||||||
'assignedUserId' => $params['assignedUserId'] ?? $userId,
|
'assignedUserId' => $params['assignedUserId'] ?? $userId,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($caseId) {
|
||||||
|
$taskData['parentType'] = 'Case';
|
||||||
|
$taskData['parentId'] = $caseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = $this->entityManager->getNewEntity('Task');
|
||||||
|
$task->set($taskData);
|
||||||
$this->entityManager->saveEntity($task);
|
$this->entityManager->saveEntity($task);
|
||||||
return ['success' => true, 'message' => "משימה \"{$params['name']}\" נוצרה בהצלחה", 'entityType' => 'Task', 'entityId' => $task->get('id')];
|
|
||||||
|
$msg = "✅ משימה \"{$params['name']}\" נוצרה בהצלחה";
|
||||||
|
if ($caseId) {
|
||||||
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
|
if ($case) {
|
||||||
|
$msg .= " בתיק " . $case->get('name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => $msg, 'entityType' => 'Task', 'entityId' => $task->get('id')];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function addNote(array $params, ?string $caseId, string $userId): array
|
private function addNote(array $params, ?string $caseId, string $userId): array
|
||||||
{
|
{
|
||||||
|
$noteData = ['type' => 'Post', 'post' => $params['post'] ?? '', 'createdById' => $userId];
|
||||||
|
|
||||||
|
if ($caseId) {
|
||||||
|
$noteData['parentType'] = 'Case';
|
||||||
|
$noteData['parentId'] = $caseId;
|
||||||
|
}
|
||||||
|
|
||||||
$note = $this->entityManager->getNewEntity('Note');
|
$note = $this->entityManager->getNewEntity('Note');
|
||||||
$note->set(['type' => 'Post', 'post' => $params['post'] ?? '', 'parentType' => 'Case', 'parentId' => $caseId, 'createdById' => $userId]);
|
$note->set($noteData);
|
||||||
$this->entityManager->saveEntity($note);
|
$this->entityManager->saveEntity($note);
|
||||||
return ['success' => true, 'message' => 'הערה נוספה לתיק', 'entityType' => 'Note', 'entityId' => $note->get('id')];
|
return ['success' => true, 'message' => '✅ הערה נוספה' . ($caseId ? ' לתיק' : ''), 'entityType' => 'Note', 'entityId' => $note->get('id')];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function changeStatus(array $params, ?string $caseId): array
|
private function changeStatus(array $params, ?string $caseId): array
|
||||||
@@ -119,16 +179,137 @@ class ActionExecutor
|
|||||||
|
|
||||||
private function createMeeting(array $params, ?string $caseId, string $userId): array
|
private function createMeeting(array $params, ?string $caseId, string $userId): array
|
||||||
{
|
{
|
||||||
$meeting = $this->entityManager->getNewEntity('Meeting');
|
$meetingData = [
|
||||||
$meeting->set([
|
|
||||||
'name' => $params['name'] ?? 'פגישה חדשה', 'status' => 'Planned',
|
'name' => $params['name'] ?? 'פגישה חדשה', 'status' => 'Planned',
|
||||||
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
|
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
|
||||||
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
|
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
|
||||||
'description' => $params['description'] ?? null,
|
'description' => $params['description'] ?? null,
|
||||||
'parentType' => 'Case', 'parentId' => $caseId, 'assignedUserId' => $userId,
|
'assignedUserId' => $userId,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($caseId) {
|
||||||
|
$meetingData['parentType'] = 'Case';
|
||||||
|
$meetingData['parentId'] = $caseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meeting = $this->entityManager->getNewEntity('Meeting');
|
||||||
|
$meeting->set($meetingData);
|
||||||
$this->entityManager->saveEntity($meeting);
|
$this->entityManager->saveEntity($meeting);
|
||||||
return ['success' => true, 'message' => "פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')];
|
return ['success' => true, 'message' => "✅ פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function 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
|
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];
|
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
|
private function normalizeDateTime(?string $value): ?string
|
||||||
{
|
{
|
||||||
if ($value === null || $value === '') return null;
|
if ($value === null || $value === '') return null;
|
||||||
@@ -228,4 +502,23 @@ class ActionExecutor
|
|||||||
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
|
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
|
||||||
return $bytes . ' B';
|
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);
|
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
|
public function getUpcomingHearings(int $days = 7): array
|
||||||
{
|
{
|
||||||
$today = date('Y-m-d');
|
$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),
|
'contacts' => $this->getCaseContacts($case),
|
||||||
'openTasks' => $this->getOpenTasks($caseId),
|
'openTasks' => $this->getOpenTasks($caseId),
|
||||||
'upcomingMeetings' => $this->getUpcomingMeetings($caseId),
|
'upcomingMeetings' => $this->getUpcomingMeetings($caseId),
|
||||||
|
'recentCalls' => $this->getRecentCalls($caseId),
|
||||||
'recentNotes' => $this->getRecentNotes($caseId),
|
'recentNotes' => $this->getRecentNotes($caseId),
|
||||||
'availableTemplates' => $this->getAvailableTemplates(),
|
'availableTemplates' => $this->getAvailableTemplates(),
|
||||||
'documents' => $this->getDocumentListing($caseId),
|
'documents' => $this->getDocumentListing($caseId),
|
||||||
@@ -113,6 +114,24 @@ class CaseContextBuilder
|
|||||||
return $meetings;
|
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
|
private function getRecentNotes(string $caseId): array
|
||||||
{
|
{
|
||||||
$notes = [];
|
$notes = [];
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ class ConversationRepository
|
|||||||
if (!$entity) return;
|
if (!$entity) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$messages = $entity->get('messagesData') ?? [];
|
$messages = json_decode(json_encode($entity->get('messagesData') ?? []), true);
|
||||||
if (is_object($messages)) $messages = (array) $messages;
|
|
||||||
|
|
||||||
$newMessage = ['role' => $role, 'content' => $content, 'timestamp' => date('Y-m-d H:i:s')];
|
$newMessage = ['role' => $role, 'content' => $content, 'timestamp' => date('Y-m-d H:i:s')];
|
||||||
if (!empty($actions)) $newMessage['actions'] = $actions;
|
if (!empty($actions)) $newMessage['actions'] = $actions;
|
||||||
@@ -81,8 +80,7 @@ class ConversationRepository
|
|||||||
$entity = $this->entityManager->getRDBRepository('AssistantConversation')
|
$entity = $this->entityManager->getRDBRepository('AssistantConversation')
|
||||||
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
|
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
|
||||||
if (!$entity) return [];
|
if (!$entity) return [];
|
||||||
$messages = $entity->get('messagesData') ?? [];
|
return json_decode(json_encode($entity->get('messagesData') ?? []), true);
|
||||||
return is_object($messages) ? (array) $messages : $messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRecentConversations(?string $type = null, ?string $caseId = null, int $limit = 20): array
|
public function getRecentConversations(?string $type = null, ?string $caseId = null, int $limit = 20): array
|
||||||
@@ -137,8 +135,7 @@ class ConversationRepository
|
|||||||
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
|
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
|
||||||
if (!$entity) return;
|
if (!$entity) return;
|
||||||
|
|
||||||
$data = $entity->get('messagesData') ?? [];
|
$data = json_decode(json_encode($entity->get('messagesData') ?? []), true);
|
||||||
if (is_object($data)) $data = (array) $data;
|
|
||||||
for ($i = count($data) - 1; $i >= 0; $i--) {
|
for ($i = count($data) - 1; $i >= 0; $i--) {
|
||||||
if (($data[$i]['role'] ?? '') === 'assistant') { $data[$i]['pendingActions'] = $actions; break; }
|
if (($data[$i]['role'] ?? '') === 'assistant') { $data[$i]['pendingActions'] = $actions; break; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use Espo\Core\Exceptions\Error;
|
|||||||
use Espo\Core\InjectableFactory;
|
use Espo\Core\InjectableFactory;
|
||||||
use Espo\Core\Utils\Log;
|
use Espo\Core\Utils\Log;
|
||||||
use Espo\ORM\EntityManager;
|
use Espo\ORM\EntityManager;
|
||||||
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageFactory;
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||||
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageInterface;
|
use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
|
||||||
|
|
||||||
class DocumentAnalyzer
|
class DocumentAnalyzer
|
||||||
{
|
{
|
||||||
@@ -43,17 +43,19 @@ class DocumentAnalyzer
|
|||||||
$casePath = $this->getCaseFolderPath($caseId);
|
$casePath = $this->getCaseFolderPath($caseId);
|
||||||
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
|
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
|
||||||
|
|
||||||
$storage = $this->getStorage();
|
$client = $this->getStorageClient();
|
||||||
$topLevel = $storage->listFolder($casePath);
|
$topLevel = $client->listFolder($casePath);
|
||||||
$folders = []; $rootFiles = []; $totalFiles = 0;
|
$folders = []; $rootFiles = []; $totalFiles = 0;
|
||||||
|
|
||||||
foreach ($topLevel as $item) {
|
foreach ($topLevel as $item) {
|
||||||
if ($item['isFolder']) {
|
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||||
|
if ($isFolder) {
|
||||||
$subFiles = [];
|
$subFiles = [];
|
||||||
try {
|
try {
|
||||||
foreach ($storage->listFolder($item['path']) as $sub) {
|
foreach ($client->listFolder($item['path']) as $sub) {
|
||||||
if (!$sub['isFolder']) {
|
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
|
||||||
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'], 'mimeType' => $sub['mimeType'], 'modified' => $sub['modified'] ?? null];
|
if (!$subIsFolder) {
|
||||||
|
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null];
|
||||||
$totalFiles++;
|
$totalFiles++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,7 @@ class DocumentAnalyzer
|
|||||||
}
|
}
|
||||||
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
|
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
|
||||||
} else {
|
} 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++;
|
$totalFiles++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,17 +74,12 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
public function extractTextContent(string $filePath): ?string
|
public function extractTextContent(string $filePath): ?string
|
||||||
{
|
{
|
||||||
$storage = $this->getStorage();
|
$client = $this->getStorageClient();
|
||||||
$parentPath = dirname($filePath);
|
|
||||||
$filename = basename($filePath);
|
$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;
|
if (strlen($content) > self::MAX_FILE_SIZE) return 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 (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
|
if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
|
||||||
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
|
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
|
||||||
@@ -107,7 +104,7 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
public function renameDocument(string $sourcePath, string $newName): array
|
public function renameDocument(string $sourcePath, string $newName): array
|
||||||
{
|
{
|
||||||
$storage = $this->getStorage();
|
$client = $this->getStorageClient();
|
||||||
$parentPath = dirname($sourcePath);
|
$parentPath = dirname($sourcePath);
|
||||||
$oldName = basename($sourcePath);
|
$oldName = basename($sourcePath);
|
||||||
|
|
||||||
@@ -120,21 +117,66 @@ class DocumentAnalyzer
|
|||||||
$destination = $parentPath . '/' . $newName;
|
$destination = $parentPath . '/' . $newName;
|
||||||
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $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);
|
$this->updateDocumentEntity($sourcePath, $destination);
|
||||||
|
|
||||||
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
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);
|
$casePath = $this->getCaseFolderPath($caseId);
|
||||||
return $case ? ($case->get('nextCloudFolderPath') ?: null) : null;
|
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
|
private function extractFromPdf(string $tmpFile): ?string
|
||||||
@@ -168,10 +210,10 @@ class DocumentAnalyzer
|
|||||||
private function updateDocumentEntity(string $oldPath, string $newPath): void
|
private function updateDocumentEntity(string $oldPath, string $newPath): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne();
|
$doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
|
||||||
if ($doc) {
|
if ($doc) {
|
||||||
$doc->set('nextCloudPath', $newPath);
|
$doc->set('networkStoragePath', $newPath);
|
||||||
$doc->set('nextCloudFilename', basename($newPath));
|
$doc->set('networkStorageFileName', basename($newPath));
|
||||||
$this->entityManager->saveEntity($doc);
|
$this->entityManager->saveEntity($doc);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {}
|
} catch (\Exception $e) {}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ class SmartAssistantService
|
|||||||
public const NOTE_TYPE_RESPONSE = 'SmartResponse';
|
public const NOTE_TYPE_RESPONSE = 'SmartResponse';
|
||||||
public const NOTE_TYPE_ACTION = 'SmartAction';
|
public const NOTE_TYPE_ACTION = 'SmartAction';
|
||||||
|
|
||||||
private const READ_ONLY_TOOLS = ['list_documents', 'save_memory'];
|
// All tools execute inline — Shira has full permissions
|
||||||
|
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
|
||||||
|
|
||||||
private EntityManager $entityManager;
|
private EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
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
|
// Generate conversation ID
|
||||||
if (!$conversationId) {
|
if (!$conversationId) {
|
||||||
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
|
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
|
||||||
@@ -103,27 +108,59 @@ class SmartAssistantService
|
|||||||
// Call webhook
|
// Call webhook
|
||||||
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
|
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
|
||||||
|
|
||||||
// Process actions
|
// Process actions — execute all inline (Shira has full permissions)
|
||||||
$actions = $response['actions'] ?? [];
|
$actions = $response['actions'] ?? [];
|
||||||
$responseText = $response['text'] ?? '';
|
$responseText = $response['text'] ?? '';
|
||||||
$inlineResults = [];
|
$inlineResults = [];
|
||||||
$remainingActions = [];
|
$executedActions = [];
|
||||||
|
|
||||||
|
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
|
||||||
|
$effectiveCaseId = $caseId;
|
||||||
|
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
|
||||||
|
$effectiveCaseId = $context['drillDown']['case']['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||||
|
|
||||||
foreach ($actions as $action) {
|
foreach ($actions as $action) {
|
||||||
$tool = $action['tool'] ?? '';
|
$tool = $action['tool'] ?? '';
|
||||||
if (in_array($tool, self::READ_ONLY_TOOLS, true)) {
|
$actionCaseId = $effectiveCaseId;
|
||||||
try {
|
|
||||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
// Resolve case from action params if not available
|
||||||
$result = $executor->execute($tool, $action['params'] ?? [], $caseId, $userId);
|
if (!$actionCaseId && !empty($action['params']['caseId'])) {
|
||||||
if (!empty($result['message'])) {
|
$actionCaseId = $action['params']['caseId'];
|
||||||
$inlineResults[] = $result['message'];
|
}
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
// Skip tools that require a case if no case context available
|
||||||
$this->log->warning("SmartAssistant: Inline execution of {$tool} failed: " . $e->getMessage());
|
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
|
||||||
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
|
||||||
|
if (!empty($result['message'])) {
|
||||||
|
$inlineResults[] = $result['message'];
|
||||||
}
|
}
|
||||||
} else {
|
$executedActions[] = [
|
||||||
$remainingActions[] = $action;
|
'tool' => $tool,
|
||||||
|
'status' => 'executed',
|
||||||
|
'entityType' => $result['entityType'] ?? null,
|
||||||
|
'entityId' => $result['entityId'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create stream note for executed action in case context
|
||||||
|
if ($actionCaseId) {
|
||||||
|
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
|
||||||
|
'conversationId' => $conversationId,
|
||||||
|
'status' => 'executed',
|
||||||
|
'entityType' => $result['entityType'] ?? null,
|
||||||
|
'entityId' => $result['entityId'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
|
||||||
|
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,25 +168,9 @@ class SmartAssistantService
|
|||||||
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store pending actions
|
|
||||||
if (!empty($remainingActions)) {
|
|
||||||
self::$conversations[$conversationId] = [
|
|
||||||
'caseId' => $caseId,
|
|
||||||
'userId' => $userId,
|
|
||||||
'actions' => $remainingActions,
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->getConversationRepo()->storePendingActions($conversationId, $caseId ?? '', $userId, $remainingActions);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->log->warning('SmartAssistant: Failed to persist actions: ' . $e->getMessage());
|
|
||||||
$this->saveConversationFile($conversationId, $caseId, $userId, $remainingActions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist response
|
// Persist response
|
||||||
try {
|
try {
|
||||||
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $remainingActions);
|
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $executedActions);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage());
|
$this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -158,14 +179,15 @@ class SmartAssistantService
|
|||||||
if ($mode === 'case' && $caseId) {
|
if ($mode === 'case' && $caseId) {
|
||||||
$this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [
|
$this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [
|
||||||
'conversationId' => $conversationId,
|
'conversationId' => $conversationId,
|
||||||
'actions' => $remainingActions,
|
'actions' => $executedActions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'conversationId' => $conversationId,
|
'conversationId' => $conversationId,
|
||||||
'text' => $responseText,
|
'text' => $responseText,
|
||||||
'actions' => $remainingActions,
|
'actions' => [],
|
||||||
|
'executedActions' => $executedActions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,14 +258,27 @@ class SmartAssistantService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getStatus(): array
|
||||||
|
{
|
||||||
|
$webhookUrl = $this->getWebhookUrl();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => $webhookUrl !== null,
|
||||||
|
'webhookConfigured' => !empty($webhookUrl),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function getSummary(): array
|
public function getSummary(): array
|
||||||
{
|
{
|
||||||
$alertCalc = $this->injectableFactory->create(AlertCalculator::class);
|
$alertCalc = $this->injectableFactory->create(AlertCalculator::class);
|
||||||
$thresholds = $this->getThresholds();
|
$thresholds = $this->getThresholds();
|
||||||
|
$hearingDays = $thresholds['upcomingHearingDays'] ?? 7;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'summary' => $alertCalc->getSummary(),
|
'summary' => $alertCalc->getSummary(),
|
||||||
'alerts' => $alertCalc->calculateAlerts($thresholds),
|
'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.');
|
throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$siteUrl = $this->config->get('siteUrl') ?? '';
|
||||||
|
|
||||||
$payload = json_encode([
|
$payload = json_encode([
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'context' => $context,
|
'context' => $context,
|
||||||
'conversationId' => $conversationId,
|
'conversationId' => $conversationId,
|
||||||
'conversationHistory' => $history,
|
'conversationHistory' => $history,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
|
'systemInstructions' => $this->getSystemInstructions(),
|
||||||
|
'espocrm' => [
|
||||||
|
'url' => rtrim($siteUrl, '/'),
|
||||||
|
'apiKey' => $this->getEspocrmApiKey(),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$headers = ['Content-Type: application/json', 'Accept: application/json'];
|
$headers = ['Content-Type: application/json', 'Accept: application/json'];
|
||||||
@@ -364,7 +406,7 @@ class SmartAssistantService
|
|||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_HTTPHEADER => $headers,
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
CURLOPT_TIMEOUT => 120,
|
CURLOPT_TIMEOUT => 180,
|
||||||
CURLOPT_CONNECTTIMEOUT => 10,
|
CURLOPT_CONNECTTIMEOUT => 10,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -406,14 +448,11 @@ class SmartAssistantService
|
|||||||
|
|
||||||
private function getWebhookUrl(): ?string
|
private function getWebhookUrl(): ?string
|
||||||
{
|
{
|
||||||
// Try SmartAssistant first, then fallback to old integrations
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
|
if ($integration && $integration->get('enabled')) {
|
||||||
$integration = $this->entityManager->getEntityById('Integration', $name);
|
$data = $integration->get('data') ?? (object) [];
|
||||||
if ($integration && $integration->get('enabled')) {
|
if (!empty($data->webhookUrl)) {
|
||||||
$data = $integration->get('data') ?? (object) [];
|
return $data->webhookUrl;
|
||||||
if (!empty($data->webhookUrl)) {
|
|
||||||
return $data->webhookUrl;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -421,30 +460,58 @@ class SmartAssistantService
|
|||||||
|
|
||||||
private function getApiKey(): ?string
|
private function getApiKey(): ?string
|
||||||
{
|
{
|
||||||
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
$integration = $this->entityManager->getEntityById('Integration', $name);
|
if ($integration && $integration->get('enabled')) {
|
||||||
if ($integration && $integration->get('enabled')) {
|
$data = $integration->get('data') ?? (object) [];
|
||||||
$data = $integration->get('data') ?? (object) [];
|
if (!empty($data->apiKey)) {
|
||||||
if (!empty($data->apiKey)) {
|
return $data->apiKey;
|
||||||
return $data->apiKey;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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
|
private function getThresholds(): array
|
||||||
{
|
{
|
||||||
foreach (['SmartAssistant', 'OfficeAssistant'] as $name) {
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
$integration = $this->entityManager->getEntityById('Integration', $name);
|
if ($integration && $integration->get('enabled')) {
|
||||||
if ($integration && $integration->get('enabled')) {
|
$data = (array) ($integration->get('data') ?? []);
|
||||||
$data = (array) ($integration->get('data') ?? []);
|
return [
|
||||||
return [
|
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
|
||||||
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
|
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
|
||||||
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
|
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
|
||||||
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
|
];
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 750 KiB |
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "Smart Assistant",
|
"name": "SmartAssistant",
|
||||||
"module": "SmartAssistant",
|
"module": "SmartAssistant",
|
||||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and n8n integration",
|
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||||
"author": "klear",
|
"author": "klear",
|
||||||
"version": "1.0.7",
|
"version": "2.5.3",
|
||||||
"acceptableVersions": [
|
"acceptableVersions": [
|
||||||
">=8.0.0"
|
">=8.0.0"
|
||||||
],
|
],
|
||||||
"releaseDate": "2026-03-31",
|
"releaseDate": "2026-04-06",
|
||||||
"php": [
|
"php": [
|
||||||
">=8.1"
|
">=8.1"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user