Compare commits

...

26 Commits

Author SHA1 Message Date
chaim bf98495198 refactor: remove getLegalAidData from CaseContextBuilder
LegalAid entity no longer exists — context builder cleaned up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:12:43 +00:00
chaim c541069cb5 feat: include LegalAid data in case context for AI assistant
Add getLegalAidData() to CaseContextBuilder so Shira can see existing
legal aid information when working on a case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:27:36 +00:00
chaim 7480e7db35 chore: add SmartAssistant v2.5.3 release zip
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:41:58 +00:00
chaim 3c6e54d233 fix: increase chat timeout from 120s to 180s to prevent tool call timeouts
Long operations like generate_initial_report were completing successfully
but the frontend showed "communication error" due to AJAX timeout. Increased
CURL timeout to 180s and frontend AJAX timeout to 180s (3 minutes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:40:57 +00:00
chaim 6c97ba2361 feat: add delete_task tool + expose delete tools to AI gateway
Shira can now delete tasks, meetings, and calls when they're no longer needed.
delete_meeting and delete_call handlers already existed but weren't exposed to the AI.

SmartAssistant v2.5.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:14:19 +00:00
chaim 94397c809b fix: reorder dashlet tabs — attention first, open cases last
Move "needs attention" tab to the right (first in RTL), make it active
by default, and move "open cases" to the left (last).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:09:50 +00:00
chaim b810c9fcff feat: SmartAssistant v2.5.0 — delete tools + expandable chat panel
Add delete_meeting, delete_call, delete_note tools for Shira.
Add expand/collapse toggle button to the floating chat window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:59:06 +00:00
chaim 0dc26f3b15 feat: SmartAssistant v2.4.0 — create_call tool + behavioral rules system
- Add create_call tool: records phone calls as Call entity (not Meeting)
- Add AssistantRule entity: persistent behavioral rules the AI follows
- Add save_rule tool: AI can save new rules when user requests them
- Add systemInstructions to webhook: call vs meeting guidance, open question tracking
- Add recentCalls to case context for AI awareness
- Add AssistantRuleContextProvider: injects active rules per mode into context

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:25:31 +00:00
61 changed files with 1672 additions and 485 deletions
+120
View File
@@ -0,0 +1,120 @@
# SmartAssistant — פרומפט פריסה לסביבת פיתוח
> **העתק את הפרומפט הבא כהודעה פותחת בשיחה חדשה של Claude Code, מתוך תיקיית הפרויקט.**
---
```
## הקשר
אני עובד על הרחבת SmartAssistant ל-EspoCRM — עוזר AI צף למשרד עורכי דין.
הקוד מוכן (v1.0.3), וצריך להשלים את הפריסה בסביבת הפיתוח.
קרא את DEPLOYMENT_REVIEW.md לסיכום מלא של מה בוצע, מה נבדק, ומה נשאר.
## סביבת הפיתוח
- EspoCRM Dev: https://espocrm.dev.marcus-law.co.il (Coolify UUID: c591uqmu49qa5kh7t2z7a2mp)
- n8n: https://n8n.dev.marcus-law.co.il
- Gitea: https://gitea.dev.marcus-law.co.il (repo: espocrm-extensions/SmartAssistant)
- קרדנשיאלים ב-Infisical (תיקיות /espocrm, /n8n)
## המשימות — שלב 1: הכנה לפריסה
בצע את המשימות הבאות בסדר. לפני כל שלב — עדכן אותי מה אתה הולך לעשות וחכה לאישור.
### 1. בדיקת תאימות סביבה
- בדוק גרסת EspoCRM בסביבת dev (צריך >= 8.0.0)
- בדוק גרסת PHP (צריך >= 8.1)
- בדוק שאין שאריות של מודולים ישנים (CrmAssistant / OfficeAssistant הוסרו מהפרויקט)
### 2. בדיקת אנטיטי AssistantConversation
- המודול משתמש באנטיטי AssistantConversation לשמירת שיחות (ראה ConversationRepository.php)
- בדוק אם האנטיטי הזה קיים ב-EspoCRM Dev
- אם לא קיים — צריך ליצור אותו. בדוק את הקוד ב-ConversationRepository.php כדי להבין אילו שדות נדרשים, והכן entityDefs JSON
### 3. בניית n8n Workflow לטיפול בצ'אט
זה הרכיב הקריטי החסר. המודול שולח webhook ל-n8n ומצפה לתשובה.
**Webhook Payload (נשלח מ-EspoCRM):**
```json
{
"message": "הודעת המשתמש",
"context": { ... הקשר תיק/משרד מלא ... },
"conversationId": "conv_xxx",
"conversationHistory": [{ "role": "user/assistant", "content": "..." }],
"mode": "office|case"
}
```
**Response נדרש (חזרה ל-EspoCRM):**
```json
{
"text": "תשובת העוזר",
"actions": [
{
"tool": "create_task|add_note|change_status|create_meeting|schedule_hearing|list_documents|analyze_document|save_memory|rename_document",
"params": { ... },
"actionId": "action_xxx",
"displayText": "תיאור הפעולה למשתמש"
}
]
}
```
**הפעולות שהעוזר יכול לבקש:**
| כלי | פרמטרים | דורש אישור |
|------|----------|------------|
| create_task | name, dateEnd, priority, description | כן |
| add_note | text | כן |
| change_status | status | כן |
| create_meeting | name, dateStart, dateEnd, description | כן |
| create_call | name, dateStart, dateEnd, description, direction, status | כן |
| schedule_hearing | date, description | כן |
| list_documents | — | לא (מיידי) |
| analyze_document | documentId | כן |
| save_memory | name, content, category, importance | לא (מיידי) |
| save_rule | name, rule, scope (global/case/office) | לא (מיידי) |
| rename_document | documentId, newName | כן |
**דרישות ל-Workflow:**
- Webhook trigger שמקבל POST
- שליחת ההודעה + הקשר ל-LLM (Claude/GPT)
- System prompt שמכיר את הכלים הזמינים ואת פורמט התשובה
- החזרת JSON תקין בפורמט למעלה
- שמירת היסטוריית שיחה (conversationId)
- טיפול בשני המצבים (office/case) עם system prompts מתאימים
### 4. הגדרת Integration ב-EspoCRM
אחרי שה-workflow מוכן:
- Admin > Integrations > Smart Assistant
- webhookUrl = כתובת ה-webhook מ-n8n
- apiKey = מפתח אימות (אם הגדרנו)
- שאר הערכים: ברירת מחדל
### 5. בדיקות ידניות ראשונות
אחרי הפריסה:
- [ ] האם ה-FAB (כפתור צ'אט) מופיע?
- [ ] האם ניתן לשלוח הודעה במצב משרד ולקבל תשובה?
- [ ] האם ניתן לנווט לתיק ולשלוח הודעה במצב תיק?
- [ ] האם פעולות מוצגות לאישור?
- [ ] האם ההתראות מופיעות ב-navbar?
- [ ] האם הדשלט מציג נתונים?
## הנחיות
- השתמש ב-MCP tools (Coolify, Gitea, n8n, Infisical) לכל פעולה
- אל תבצע פעולות הרסניות בלי אישור שלי
- אם נתקלת בבעיה — תאר מה קרה ומה ניסית, ותשאל מה לעשות
- עדכן את DEPLOYMENT_REVIEW.md עם כל ממצא חדש
```
---
## הערות שימוש
- **היכן להריץ:** בתיקיית `/home/chaim/espocrm-extensions/SmartAssistant`
- **MCP נדרשים:** coolify-dev, gitea-dev, n8n-dev, infisical
- **משך משוער:** השלב הקריטי הוא בניית ה-n8n workflow (#3) — זה ידרוש עיצוב system prompts ובדיקות
- **מה הפרומפט לא כולל:** הסרת מודולים ישנים (תלוי בממצאי שלב 1), הוספת תרגום he_IL (שיפור עתידי)
+454
View File
@@ -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 -3
View File
@@ -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.
Binary file not shown.
+31
View File
@@ -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
תוכן הטבלה — כותרות ושורות. אם לא צוין, שאל את המשתמש מה להציג.
+62
View File
@@ -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,7 +24,7 @@ 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';
var self = this; var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) { Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
@@ -45,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 () {});
@@ -52,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;
@@ -74,6 +76,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
}, },
showDetail: function (type) { showDetail: function (type) {
var self = this;
var $detail = this.$el.find('.sa-dashlet-detail'); var $detail = this.$el.find('.sa-dashlet-detail');
if (!type || type === this._activeType && $detail.html()) { if (!type || type === this._activeType && $detail.html()) {
@@ -93,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) {
@@ -117,7 +120,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
html += '<div style="padding: 6px 8px; border-bottom: 1px solid #f0f0f0; font-size: 12px; color: ' + color + ';">' + 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);
@@ -168,7 +190,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
self.$el.find('.sa-fab').show(); self.$el.find('.sa-fab').show();
self.$el.find('.sa-chat-messages').attr('data-empty-text', self.$el.find('.sa-chat-messages').attr('data-empty-text',
self.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם העוזר החכם'); self.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם שירה');
self.loadAlertCount(); self.loadAlertCount();
self._setupRouteListener(); self._setupRouteListener();
}).catch(function () { }).catch(function () {
@@ -246,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 () {
@@ -417,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,
@@ -433,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);
}); });
} }
@@ -480,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();
@@ -607,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, '&bull; $1');
return text; // Split into lines for table detection
var lines = text.split('\n');
var result = [];
var i = 0;
while (i < lines.length) {
// Detect markdown table: line with |, followed by separator |---|, followed by more | lines
if (lines[i].indexOf('|') !== -1 && i + 1 < lines.length && /^\|?[\s\-:|]+\|/.test(lines[i + 1])) {
var tableLines = [];
var j = i;
// Collect all contiguous lines that contain |
while (j < lines.length && lines[j].indexOf('|') !== -1) {
tableLines.push(lines[j]);
j++;
}
if (tableLines.length >= 2) {
result.push(self.renderMarkdownTable(tableLines, LRM));
i = j;
continue;
}
}
// Regular line — escape and format
var line = self.escapeHtml(lines[i]);
line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Headings
line = line.replace(/^### (.+)/, '<strong style="font-size: 14px;">$1</strong>');
line = line.replace(/^## (.+)/, '<strong style="font-size: 15px;">$1</strong>');
// List items
line = line.replace(/^[-*] (.+)/, '&bull; $1');
// Numbered lists
line = line.replace(/^(\d+)\. (.+)/, '$1. $2');
result.push(line);
i++;
}
return result.join('<br>');
},
renderMarkdownTable: function (lines, LRM) {
var self = this;
var parseRow = function (line) {
// Remove leading/trailing |, split by |
var trimmed = line.replace(/^\|/, '').replace(/\|$/, '');
return trimmed.split('|').map(function (cell) { return cell.trim(); });
};
// First line = headers
var headers = parseRow(lines[0]);
// Skip separator line (line[1])
// Remaining lines = data rows
var rows = [];
for (var i = 2; i < lines.length; i++) {
// Skip if it looks like another separator
if (/^[\s\-:|]+$/.test(lines[i].replace(/\|/g, ''))) continue;
rows.push(parseRow(lines[i]));
}
// Build HTML table with LRM for BiDi safety
var html = '<table class="sa-table" dir="rtl">';
// Header
html += '<thead><tr>';
headers.forEach(function (h) {
var escaped = self.escapeHtml(h).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html += '<th>' + LRM + escaped + LRM + '</th>';
});
html += '</tr></thead>';
// Body
html += '<tbody>';
rows.forEach(function (row) {
html += '<tr>';
for (var c = 0; c < headers.length; c++) {
var cell = row[c] !== undefined ? row[c] : '';
var escaped = self.escapeHtml(cell).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html += '<td>' + LRM + escaped + LRM + '</td>';
}
html += '</tr>';
});
html += '</tbody></table>';
return html;
}, },
escapeHtml: function (text) { escapeHtml: function (text) {
@@ -620,5 +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') {
@@ -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;
}
}
@@ -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": "ימים לאזהרה קריטית",
@@ -3,14 +3,14 @@
"Smart Assistant": "שירה", "Smart Assistant": "שירה",
"Office Mode": "מצב משרד", "Office Mode": "מצב משרד",
"Case Mode": "מצב תיק", "Case Mode": "מצב תיק",
"Add Memory": "הוסף זיכרון", "Add Memory": "הוספת זיכרון",
"Search Memory": פש בזיכרון", "Search Memory": יפוש בזיכרון",
"New Conversation": "שיחה חדשה", "New Conversation": "שיחה חדשה",
"Conversation History": "היסטוריית שיחות", "Conversation History": "היסטוריית שיחות",
"Previous Conversations": "שיחות קודמות", "Previous Conversations": "שיחות קודמות",
"No previous conversations": "אין שיחות קודמות", "No previous conversations": "אין שיחות קודמות",
"Ask the assistant...": "שאל/י את שירה...", "Ask the assistant...": "שאל/י את שירה...",
"Thinking": "חושב...", "Thinking": "חושבת...",
"Error": "שגיאה בתקשורת", "Error": "שגיאה בתקשורת",
"Back": "חזרה", "Back": "חזרה",
"Chat with the assistant": "שוחח/י עם שירה", "Chat with the assistant": "שוחח/י עם שירה",
@@ -0,0 +1,7 @@
{
"itemViews": {
"SmartRequest": "modules/smart-assistant/views/stream/notes/smart-request",
"SmartResponse": "modules/smart-assistant/views/stream/notes/smart-response",
"SmartAction": "modules/smart-assistant/views/stream/notes/smart-action"
}
}
@@ -0,0 +1,56 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 255,
"required": true
},
"rule": {
"type": "text",
"required": true
},
"scope": {
"type": "enum",
"options": ["global", "case", "office"],
"default": "global"
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
}
},
"indexes": {
"isActive": {
"columns": ["isActive", "deleted"]
},
"scope": {
"columns": ["scope", "isActive", "deleted"]
}
}
}
@@ -8,6 +8,10 @@
"type": "varchar", "type": "varchar",
"maxLength": 255 "maxLength": 255
}, },
"espocrmApiKey": {
"type": "varchar",
"maxLength": 255
},
"maxMessagesPerHour": { "maxMessagesPerHour": {
"type": "int", "type": "int",
"default": 30 "default": 30
@@ -0,0 +1,11 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "scope"}],
[{"name": "isActive"}, false],
[{"name": "rule", "fullWidth": true}],
[{"name": "createdAt"}, {"name": "createdBy"}]
]
}
]
@@ -0,0 +1,7 @@
[
{"name": "name", "width": 35, "link": true},
{"name": "rule", "width": 30},
{"name": "scope", "width": 10},
{"name": "isActive", "width": 10},
{"name": "createdAt", "width": 15}
]
@@ -0,0 +1,11 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "team", "own", "no"],
"stream": false,
"tab": false,
"disabled": false
}
@@ -7,18 +7,21 @@ use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory; use Espo\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,
]; ];
} }
@@ -250,10 +272,13 @@ class SmartAssistantService
{ {
$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),
]; ];
} }
@@ -353,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'];
@@ -374,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,
]); ]);
@@ -416,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;
@@ -431,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 [];
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

+4 -4
View File
@@ -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.9", "version": "2.5.3",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"releaseDate": "2026-03-29", "releaseDate": "2026-04-06",
"php": [ "php": [
">=8.1" ">=8.1"
] ]