diff --git a/appinfo/routes.php b/appinfo/routes.php index 1022f74c3..63383af03 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -48,6 +48,7 @@ ['name' => 'chattyLLM#deleteMessage', 'url' => '/chat/delete_message', 'verb' => 'DELETE'], ['name' => 'chattyLLM#getMessages', 'url' => '/chat/messages', 'verb' => 'GET'], ['name' => 'chattyLLM#getMessage', 'url' => '/chat/sessions/{sessionId}/messages/{messageId}', 'verb' => 'GET'], + ['name' => 'chattyLLM#searchMessages', 'url' => '/chat/search', 'verb' => 'GET'], ['name' => 'chattyLLM#generateForSession', 'url' => '/chat/generate', 'verb' => 'GET'], ['name' => 'chattyLLM#regenerateForSession', 'url' => '/chat/regenerate', 'verb' => 'GET'], ['name' => 'chattyLLM#checkSession', 'url' => '/chat/check_session', 'verb' => 'GET'], diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 765d8518d..d104afc12 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -466,6 +466,33 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { } } + /** + * Search chat messages + * + * Search through all chat messages for the current user + * + * @param string $query The search query + * @return JSONResponse, sessionIds: list}, array{}>|JSONResponse + * + * 200: Search results returned successfully + * 401: Not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] + public function searchMessages(string $query): JSONResponse { + + try { + $result = $this->chatService->searchMessages($this->userId, $query); + return new JSONResponse($result); + } catch (InternalException $e) { + $this->logger->warning('Failed to search chat messages', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to search chat messages')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + } + + /** * Generate a new assistant message * diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index a0c4c0ce8..454fc336d 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -181,4 +181,24 @@ public function deleteMessageById(int $sessionId, int $messageId): void { $qb->executeStatement(); } + + /** + * @param string $userId + * @param string $query + * @param int $limit + * @return list + * @throws \OCP\DB\Exception + */ + public function searchMessages(string $userId, string $query, int $limit = 100): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(Message::$columns) + ->from($this->getTableName(), 'm') + ->join('m', 'assistant_chat_sns', 's', $qb->expr()->eq('m.session_id', 's.id')) + ->where($qb->expr()->eq('s.user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->iLike('m.content', $qb->createPositionalParameter('%' . $this->db->escapeLikeParameter($query) . '%', IQueryBuilder::PARAM_STR))) + ->orderBy('m.timestamp', 'DESC') + ->setMaxResults($limit); + + return $this->findEntities($qb); + } } diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index cfddf61d3..3df748316 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -168,6 +168,32 @@ public function getSessionsForUser(?string $userId): array { } } + /** + * @return array{messages: list>, sessionIds: list} + * @throws UnauthorizedException + * @throws InternalException + */ + public function searchMessages(?string $userId, string $query): array { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + // For empty queries return two empty lists right away + if (trim($query) === '') { + return ['messages' => [], 'sessionIds' => []]; + } + try { + $messages = $this->messageMapper->searchMessages($userId, $query); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + $sessionIds = array_values(array_unique( + array_map(fn(Message $m) => $m->getSessionId(), $messages) + )); + return [ + 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays + 'sessionIds' => $sessionIds, + ]; + } /** * @throws BadRequestException * @throws InternalException diff --git a/tests/unit/Service/ChatServiceSearchTest.php b/tests/unit/Service/ChatServiceSearchTest.php new file mode 100644 index 000000000..7f1514190 --- /dev/null +++ b/tests/unit/Service/ChatServiceSearchTest.php @@ -0,0 +1,130 @@ +messageMapper = $this->createMock(MessageMapper::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $this->service = new ChatService( + $this->createMock(IUserManager::class), + $this->createMock(IAppConfig::class), + $l10n, + $this->createMock(SessionMapper::class), + $this->messageMapper, + $this->createMock(SessionSummaryService::class), + $this->createMock(IManager::class), + $this->createMock(LoggerInterface::class), + $this->createMock(ITimeFactory::class), + ); + } + + public function testSearchMessagesUserIdNull(): void { + // UserId = null should throw an error + $this->expectException(UnauthorizedException::class); + $this->service->searchMessages(null, 'hello'); + } + + public function testSearchMessagesBlankQuery(): void { + // A blank query should not hit the database but return empty + $this->messageMapper->expects($this->never()) + ->method('searchMessages'); + + $result = $this->service->searchMessages('user1', ' '); + + $this->assertSame([], $result['messages']); + $this->assertSame([], $result['sessionIds']); + } + + public function testSearchMessagesSameSession(): void { + // Two messages from the same session + $msg1 = new Message(); + $msg1->setSessionId(1); + $msg1->setRole(Message::ROLE_HUMAN); + $msg1->setContent('hello assistant'); + $msg1->setTimestamp(1000); + $msg1->setSources('[]'); + $msg1->setAttachments('[]'); + + $msg2 = new Message(); + $msg2->setSessionId(1); + $msg2->setRole(Message::ROLE_ASSISTANT); + $msg2->setContent('hello human'); + $msg2->setTimestamp(1001); + $msg2->setSources('[]'); + $msg2->setAttachments('[]'); + + $this->messageMapper->expects($this->once()) + ->method('searchMessages') + ->with('user1', 'hello') + ->willReturn([$msg1, $msg2]); + + $result = $this->service->searchMessages('user1', 'hello'); + + // Two messages returned + $this->assertCount(2, $result['messages']); + // Check that the messages have the same session ID + $this->assertSame([1], $result['sessionIds']); + } + + public function testSearchMessagesDifferentSession(): void { + $msg1 = new Message(); + $msg1->setSessionId(2); + $msg1->setRole(Message::ROLE_HUMAN); + $msg1->setContent('hello from session 2'); + $msg1->setTimestamp(1000); + $msg1->setSources('[]'); + $msg1->setAttachments('[]'); + + $msg2 = new Message(); + $msg2->setSessionId(3); + $msg2->setRole(Message::ROLE_HUMAN); + $msg2->setContent('hello from session 3'); + $msg2->setTimestamp(1001); + $msg2->setSources('[]'); + $msg2->setAttachments('[]'); + + $this->messageMapper->expects($this->once()) + ->method('searchMessages') + ->with('user1', 'hello') + ->willReturn([$msg1, $msg2]); + + $result = $this->service->searchMessages('user1', 'hello'); + + $this->assertCount(2, $result['messages']); + + // Check that the messages have different session IDs + $this->assertSame([2, 3], $result['sessionIds']); + } +} \ No newline at end of file