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..3072e70da 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(array_map(fn ($col) => 'm.' . $col, 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..3ad14a902 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/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 31e613941..51ef922ee 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -13,6 +13,9 @@ +
{{ t('assistant', 'Loading conversations…') }} @@ -20,8 +23,11 @@
{{ t('assistant', 'No conversations yet') }}
+
+ {{ t('assistant', 'No conversations match your search') }} +
generateOcsUrl('/apps/assistant/chat' + endpoint) + const Roles = { HUMAN: 'human', ASSISTANT: 'assistant', @@ -247,6 +255,7 @@ export default { NcAppNavigationItem, NcAppNavigationList, NcAppNavigationNew, + NcAppNavigationSearch, NcButton, NcLoadingIcon, NcDialog, @@ -279,6 +288,7 @@ export default { newSession: false, messageDelete: false, sessionDelete: false, + search: false, }, msgCursor: 0, msgLimit: 20, @@ -303,6 +313,9 @@ export default { message: t('assisant', 'Which actions can you do for me?'), }, ], + searchQuery: '', + searchResults: null, + searchDebounceTimer: null, } }, @@ -315,6 +328,13 @@ export default { const sessionTitle = this.getSessionTitle(session)?.trim() return t('assistant', 'Are you sure you want to delete "{sessionTitle}"?', { sessionTitle }) }, + + filteredSessions() { + if (!this.sessions || this.searchResults === null) { + return this.sessions + } + return this.sessions.filter(s => this.searchResults.sessionIds.includes(s.id)) + }, }, watch: { @@ -392,6 +412,17 @@ export default { this.loading.titleGeneration = false } }, + + searchQuery(newVal) { + clearTimeout(this.searchDebounceTimer) + if (!newVal.trim()) { + this.searchResults = null + return + } + this.searchDebounceTimer = setTimeout(() => { + this.performSearch(newVal.trim()) + }, 350) + }, }, beforeUnmount() { @@ -913,6 +944,20 @@ export default { const url = generateUrl('/apps/assistant/config') return axios.put(url, req) }, + + async performSearch(query) { + this.loading.search = true + try { + const response = await axios.get(getChatURL('/search'), { params: { query } }) + this.searchResults = response.data + } catch (error) { + console.error('Search error:', error) + showError(error?.response?.data?.error ?? t('assistant', 'Error searching messages')) + this.searchResults = null + } finally { + this.loading.search = false + } + }, }, } diff --git a/tests/unit/Service/ChatServiceSearchTest.php b/tests/unit/Service/ChatServiceSearchTest.php new file mode 100644 index 000000000..945533e50 --- /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']); + } +}