Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f400f2fec7 |
@@ -2,5 +2,5 @@
|
||||
"currentTag": "master",
|
||||
"lastSwitched": "2026-04-14T06:05:06.044Z",
|
||||
"branchTagMapping": {},
|
||||
"migrationNoticeShown": false
|
||||
"migrationNoticeShown": true
|
||||
}
|
||||
@@ -2,21 +2,38 @@
|
||||
"master": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"id": "1",
|
||||
"title": "fix: broaden task detection to include Contact-linked tasks",
|
||||
"description": "AlertCalculator, CaseContextBuilder, and OfficeContextBuilder only queried tasks with parentType='Case', missing tasks linked to case contacts (parentType='Contact'). This caused false 'no preparation task' alerts in Shira's daily standup.",
|
||||
"status": "in-progress",
|
||||
"status": "done",
|
||||
"priority": "high",
|
||||
"dependencies": [],
|
||||
"details": "Root cause confirmed via production API: preparation tasks had parentType='Contact' because Shira created them linked to the contact entity. Fix broadens all task queries to include both Case and Contact parent types, plus NhActivity-linked tasks.",
|
||||
"testStrategy": "Deploy to staging, trigger standup for a case with Contact-linked tasks, verify no false alert.",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-14T06:08:19.494Z"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"title": "Network-storage diagnostics + folder discovery",
|
||||
"description": "Expose diagnostics when a case folder can't be listed and add endpoints (findCaseFolder, setCaseFolderPath) so Shira can locate and persist the correct network-storage path.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "in-progress",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-19T06:11:17.422Z"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created": "2026-04-14T06:05:06.042Z",
|
||||
"updated": "2026-04-14T09:15:00.000Z",
|
||||
"description": "Tasks for master context"
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-04-19T06:11:17.423Z",
|
||||
"taskCount": 2,
|
||||
"completedCount": 1,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# SmartAssistant - עוזר חכם
|
||||
|
||||
**גרסה:** 2.7.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
**גרסה:** 2.7.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
|
||||
## תיאור
|
||||
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
||||
|
||||
@@ -217,6 +217,20 @@ class SmartAssistant
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionGetDocumentBytes(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$filePath = $data->filePath ?? null;
|
||||
if (empty($filePath)) {
|
||||
throw new BadRequest('filePath is required.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return $analyzer->getDocumentBytes($filePath);
|
||||
}
|
||||
|
||||
public function postActionReadMultipleDocuments(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
@@ -279,6 +293,75 @@ class SmartAssistant
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionListDocuments(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
if (empty($caseId)) {
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return $analyzer->listDocumentsRecursive($caseId);
|
||||
}
|
||||
|
||||
public function postActionBrowseFolder(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$path = $data->path ?? null;
|
||||
if ($path === null) {
|
||||
throw new BadRequest('path is required.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return [
|
||||
'path' => $path,
|
||||
'entries' => $analyzer->browseFolder((string) $path),
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionFindCaseFolder(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$query = $data->query ?? null;
|
||||
if (empty($query)) {
|
||||
throw new BadRequest('query is required.');
|
||||
}
|
||||
|
||||
$limit = isset($data->limit) ? max(1, min((int) $data->limit, 50)) : 20;
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return [
|
||||
'query' => $query,
|
||||
'matches' => $analyzer->findCaseFolder((string) $query, $limit),
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionSetCaseFolderPath(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
if (!$this->acl->checkScope('Case', 'edit')) {
|
||||
throw new Forbidden('No edit access to Case.');
|
||||
}
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
$caseId = $data->caseId ?? null;
|
||||
$path = $data->path ?? null;
|
||||
|
||||
if (empty($caseId) || empty($path)) {
|
||||
throw new BadRequest('caseId and path are required.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return $analyzer->setCaseFolderPath((string) $caseId, (string) $path);
|
||||
}
|
||||
|
||||
public function postActionGenerateFromTemplate(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
|
||||
@@ -33,6 +33,7 @@ class CaseContextBuilder
|
||||
'recentNotes' => $this->getRecentNotes($caseId),
|
||||
'availableTemplates' => $this->getAvailableTemplates(),
|
||||
'documents' => $this->getDocumentListing($caseId),
|
||||
'signatureRequests' => $this->getSignatureRequests($caseId),
|
||||
'currentUser' => $this->getUserData($userId),
|
||||
'validStatuses' => ActionExecutor::VALID_STATUSES,
|
||||
];
|
||||
@@ -202,13 +203,51 @@ class CaseContextBuilder
|
||||
return ['id' => $user->get('id'), 'name' => $user->get('name')];
|
||||
}
|
||||
|
||||
private function getSignatureRequests(string $caseId): array
|
||||
{
|
||||
try {
|
||||
$collection = $this->entityManager->getRDBRepository('SignatureRequest')
|
||||
->where([
|
||||
'caseId' => $caseId,
|
||||
'status!=' => ['Completed', 'Voided'],
|
||||
])
|
||||
->order('createdAt', 'DESC')
|
||||
->limit(0, 10)
|
||||
->find();
|
||||
|
||||
$requests = [];
|
||||
|
||||
foreach ($collection as $sr) {
|
||||
$requests[] = [
|
||||
'id' => $sr->get('id'),
|
||||
'name' => $sr->get('name'),
|
||||
'status' => $sr->get('status'),
|
||||
'sentAt' => $sr->get('sentAt'),
|
||||
];
|
||||
}
|
||||
|
||||
return $requests;
|
||||
} catch (\Exception $e) {
|
||||
// SignatureRequest entity might not exist if DigitalSignature not installed
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function getDocumentListing(string $caseId): array
|
||||
{
|
||||
try {
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$result = $analyzer->listDocumentsRecursive($caseId);
|
||||
|
||||
$simplified = ['totalFiles' => $result['totalFiles'], 'folders' => [], 'rootFiles' => []];
|
||||
$simplified = [
|
||||
'totalFiles' => $result['totalFiles'],
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => $result['resolvedPath'] ?? null,
|
||||
'pathSource' => $result['pathSource'] ?? null,
|
||||
'folderExists' => $result['folderExists'] ?? null,
|
||||
'diagnostic' => $result['reason'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($result['folders'] as $folder) {
|
||||
$files = [];
|
||||
|
||||
@@ -41,11 +41,67 @@ class DocumentAnalyzer
|
||||
|
||||
public function listDocumentsRecursive(string $caseId): array
|
||||
{
|
||||
$casePath = $this->getCaseFolderPath($caseId);
|
||||
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
|
||||
$description = $this->describeCaseFolderPath($caseId);
|
||||
$casePath = $description['path'];
|
||||
|
||||
if (!$casePath) {
|
||||
return [
|
||||
'casePath' => null,
|
||||
'totalFiles' => 0,
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => null,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => false,
|
||||
'reason' => $description['error'] ?? 'Case has no networkStorageFolderPath and no default could be computed.',
|
||||
];
|
||||
}
|
||||
|
||||
$client = $this->getStorageClient();
|
||||
$topLevel = $client->listFolder($casePath);
|
||||
|
||||
try {
|
||||
$folderExists = $client->exists($casePath);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'casePath' => $casePath,
|
||||
'totalFiles' => 0,
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => $casePath,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => false,
|
||||
'reason' => "Storage client failed while checking '{$casePath}': " . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$folderExists) {
|
||||
return [
|
||||
'casePath' => $casePath,
|
||||
'totalFiles' => 0,
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => $casePath,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => false,
|
||||
'reason' => "Folder '{$casePath}' (source: {$description['source']}) does not exist on storage.",
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$topLevel = $client->listFolder($casePath);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'casePath' => $casePath,
|
||||
'totalFiles' => 0,
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => $casePath,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => true,
|
||||
'reason' => "Failed to list folder '{$casePath}': " . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$folders = []; $rootFiles = []; $totalFiles = 0;
|
||||
|
||||
foreach ($topLevel as $item) {
|
||||
@@ -70,7 +126,16 @@ class DocumentAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
return ['casePath' => $casePath, 'totalFiles' => $totalFiles, 'folders' => $folders, 'rootFiles' => $rootFiles];
|
||||
return [
|
||||
'casePath' => $casePath,
|
||||
'totalFiles' => $totalFiles,
|
||||
'folders' => $folders,
|
||||
'rootFiles' => $rootFiles,
|
||||
'resolvedPath' => $casePath,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => true,
|
||||
'reason' => $totalFiles === 0 ? "Folder '{$casePath}' exists but is empty." : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function extractTextContent(string $filePath): ?string
|
||||
@@ -115,6 +180,85 @@ class DocumentAnalyzer
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw file bytes + metadata for OCR/vision fallback.
|
||||
*
|
||||
* @return array{success: bool, fileName: string, mimeType: string, sizeBytes: int, base64: ?string, error: ?string}
|
||||
*/
|
||||
public function getDocumentBytes(string $filePath): array
|
||||
{
|
||||
$fileName = basename($filePath);
|
||||
$client = $this->getStorageClient();
|
||||
|
||||
try {
|
||||
$content = $client->downloadFile($filePath);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'fileName' => $fileName,
|
||||
'mimeType' => '',
|
||||
'sizeBytes' => 0,
|
||||
'base64' => null,
|
||||
'error' => 'Failed to download: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$sizeBytes = strlen($content);
|
||||
if ($sizeBytes > self::MAX_FILE_SIZE) {
|
||||
return [
|
||||
'success' => false,
|
||||
'fileName' => $fileName,
|
||||
'mimeType' => '',
|
||||
'sizeBytes' => $sizeBytes,
|
||||
'base64' => null,
|
||||
'error' => 'File too large (max 10MB).',
|
||||
];
|
||||
}
|
||||
|
||||
$mimeType = $this->guessMimeType($fileName, $content);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'fileName' => $fileName,
|
||||
'mimeType' => $mimeType,
|
||||
'sizeBytes' => $sizeBytes,
|
||||
'base64' => base64_encode($content),
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function guessMimeType(string $fileName, string $content): string
|
||||
{
|
||||
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
$map = [
|
||||
'pdf' => 'application/pdf',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'doc' => 'application/msword',
|
||||
'txt' => 'text/plain',
|
||||
'csv' => 'text/csv',
|
||||
'log' => 'text/plain',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'bmp' => 'image/bmp',
|
||||
'tiff' => 'image/tiff',
|
||||
'tif' => 'image/tiff',
|
||||
'webp' => 'image/webp',
|
||||
];
|
||||
if (isset($map[$ext])) {
|
||||
return $map[$ext];
|
||||
}
|
||||
// Fallback: finfo
|
||||
if (function_exists('finfo_buffer')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, $content);
|
||||
finfo_close($finfo);
|
||||
if ($mime) return $mime;
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
|
||||
{
|
||||
$results = [];
|
||||
@@ -192,24 +336,160 @@ class DocumentAnalyzer
|
||||
}
|
||||
|
||||
public function getCaseFolderPath(string $caseId): ?string
|
||||
{
|
||||
return $this->describeCaseFolderPath($caseId)['path'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the case folder path with diagnostic metadata.
|
||||
*
|
||||
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
|
||||
* source: 'stored' | 'computed' | 'none'
|
||||
*/
|
||||
public function describeCaseFolderPath(string $caseId): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) return null;
|
||||
if (!$case) {
|
||||
return ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false];
|
||||
}
|
||||
|
||||
// Try stored path first
|
||||
$storedPath = $case->get('networkStorageFolderPath') ?: null;
|
||||
if ($storedPath) return $storedPath;
|
||||
if ($storedPath) {
|
||||
return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true];
|
||||
}
|
||||
|
||||
// Fall back to computed path from NetworkDocumentService
|
||||
try {
|
||||
$nds = $this->getNetworkDocumentService();
|
||||
return $nds->getEntityFolderPath('Case', $caseId);
|
||||
$computed = $nds->getEntityFolderPath('Case', $caseId);
|
||||
if ($computed) {
|
||||
return ['path' => $computed, 'source' => 'computed', 'error' => null, 'caseExists' => true];
|
||||
}
|
||||
return ['path' => null, 'source' => 'none', 'error' => 'No stored path, and default path computation returned null.', 'caseExists' => true];
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
|
||||
return null;
|
||||
$this->log->warning("SmartAssistant: Failed to compute case folder path: " . $e->getMessage());
|
||||
return ['path' => null, 'source' => 'none', 'error' => $e->getMessage(), 'caseExists' => true];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the immediate contents of an arbitrary folder path on network storage.
|
||||
* Manual override for when auto-resolution of a case folder fails.
|
||||
*
|
||||
* @return array<int, array{name: string, path: string, type: string, size: ?int, mimeType: ?string, modified: ?string}>
|
||||
*/
|
||||
public function browseFolder(string $path): array
|
||||
{
|
||||
$client = $this->getStorageClient();
|
||||
$entries = [];
|
||||
foreach ($client->listFolder($path) as $item) {
|
||||
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||
$entries[] = [
|
||||
'name' => $item['name'] ?? '',
|
||||
'path' => $item['path'] ?? '',
|
||||
'type' => $isFolder ? 'folder' : 'file',
|
||||
'size' => $item['size'] ?? null,
|
||||
'mimeType' => $item['mimeType'] ?? null,
|
||||
'modified' => $item['modified'] ?? null,
|
||||
];
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the storage for folders matching a query (case-insensitive substring).
|
||||
* Scans root and one level deep. Returns sorted matches.
|
||||
*
|
||||
* @return array<int, array{path: string, name: string, level: int, score: int}>
|
||||
*/
|
||||
public function findCaseFolder(string $query, int $limit = 20): array
|
||||
{
|
||||
$normalized = trim($query);
|
||||
if ($normalized === '') return [];
|
||||
|
||||
$client = $this->getStorageClient();
|
||||
$needle = mb_strtolower($normalized);
|
||||
$matches = [];
|
||||
|
||||
try {
|
||||
$roots = $client->listFolder('');
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning("SmartAssistant: findCaseFolder root listing failed: " . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($roots as $item) {
|
||||
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||
if (!$isFolder) continue;
|
||||
|
||||
$name = (string) ($item['name'] ?? '');
|
||||
$path = (string) ($item['path'] ?? $name);
|
||||
|
||||
$score = $this->fuzzyScore($name, $needle);
|
||||
if ($score > 0) {
|
||||
$matches[] = ['path' => $path, 'name' => $name, 'level' => 0, 'score' => $score];
|
||||
}
|
||||
|
||||
// One level deeper, but only inspect folder names — don't list files to keep it cheap.
|
||||
try {
|
||||
foreach ($client->listFolder($path) as $sub) {
|
||||
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
|
||||
if (!$subIsFolder) continue;
|
||||
$subName = (string) ($sub['name'] ?? '');
|
||||
$subPath = (string) ($sub['path'] ?? ($path . '/' . $subName));
|
||||
$subScore = $this->fuzzyScore($subName, $needle);
|
||||
if ($subScore > 0) {
|
||||
$matches[] = ['path' => $subPath, 'name' => $subName, 'level' => 1, 'score' => $subScore];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Non-fatal: skip unreadable subfolders silently.
|
||||
}
|
||||
}
|
||||
|
||||
usort($matches, fn($a, $b) => $b['score'] <=> $a['score']);
|
||||
return array_slice($matches, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the resolved folder path on the Case so future calls skip discovery.
|
||||
*/
|
||||
public function setCaseFolderPath(string $caseId, string $path): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) {
|
||||
throw new Error("Case '{$caseId}' not found.");
|
||||
}
|
||||
|
||||
$client = $this->getStorageClient();
|
||||
if (!$client->exists($path)) {
|
||||
throw new Error("Path '{$path}' does not exist on storage.");
|
||||
}
|
||||
|
||||
$case->set('networkStorageFolderPath', $path);
|
||||
$this->entityManager->saveEntity($case);
|
||||
|
||||
return ['success' => true, 'caseId' => $caseId, 'path' => $path];
|
||||
}
|
||||
|
||||
private function fuzzyScore(string $haystack, string $needleLower): int
|
||||
{
|
||||
$hay = mb_strtolower($haystack);
|
||||
if ($hay === $needleLower) return 100;
|
||||
if (mb_strpos($hay, $needleLower) !== false) return 80;
|
||||
|
||||
// Token overlap (whitespace/dash/underscore).
|
||||
$hayTokens = preg_split('/[\s\-_]+/u', $hay) ?: [];
|
||||
$needleTokens = preg_split('/[\s\-_]+/u', $needleLower) ?: [];
|
||||
$hits = 0;
|
||||
foreach ($needleTokens as $nt) {
|
||||
if ($nt === '') continue;
|
||||
foreach ($hayTokens as $ht) {
|
||||
if ($ht !== '' && mb_strpos($ht, $nt) !== false) { $hits++; break; }
|
||||
}
|
||||
}
|
||||
return $hits > 0 ? 40 + $hits * 5 : 0;
|
||||
}
|
||||
|
||||
private function getMaxTextLength(): int
|
||||
{
|
||||
if ($this->maxTextLength !== null) {
|
||||
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
"module": "SmartAssistant",
|
||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||
"author": "klear",
|
||||
"version": "2.7.3",
|
||||
"version": "2.8.0",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"releaseDate": "2026-04-14",
|
||||
"releaseDate": "2026-04-19",
|
||||
"php": [
|
||||
">=8.1"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user