From a2f752d36aa9322878cb453fa29a22a2315ac992 Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Fri, 5 Jun 2026 16:50:18 +0200 Subject: [PATCH 1/8] feat(search): add chat message search to mapper and service with unit tests Signed-off-by: Anna Visman --- lib/Db/ChattyLLM/MessageMapper.php | 29 +++++ lib/Service/ChatService.php | 25 ++++ tests/unit/Service/ChatServiceSearchTest.php | 130 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/unit/Service/ChatServiceSearchTest.php diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index a0c4c0ce8..bab91013e 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -181,4 +181,33 @@ 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..b9ea135b3 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -168,6 +168,31 @@ 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')); + } + 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), + '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 From ce256b2c7d09fa3c1bf7cb70ca34cc9749ad49be Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:04:57 +0200 Subject: [PATCH 2/8] feat(search): add search endpoint to ChattyLLMController and route Signed-off-by: Anna Visman --- appinfo/routes.php | 1 + lib/Controller/ChattyLLMController.php | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) 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..9655df521 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -466,6 +466,32 @@ 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 (UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + } + + /** * Generate a new assistant message * From ef7416bfd8b218ab16d355e4d380e49a6876fd8e Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:07:01 +0200 Subject: [PATCH 3/8] enable CI Signed-off-by: Anna Visman From 002a45dd58a6aafef87efee5c3e875ae2d5013cb Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:28:23 +0200 Subject: [PATCH 4/8] small formatting changes to make the code in line with the existing code Signed-off-by: Anna Visman --- lib/Controller/ChattyLLMController.php | 3 ++- lib/Db/ChattyLLM/MessageMapper.php | 15 +++------------ lib/Service/ChatService.php | 3 ++- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 9655df521..d104afc12 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -480,13 +480,14 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { #[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 (UnauthorizedException $e) { + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index bab91013e..454fc336d 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -193,18 +193,9 @@ public function searchMessages(string $userId, string $query, int $limit = 100): $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 - ) - )) + ->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); diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index b9ea135b3..3df748316 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -177,6 +177,7 @@ 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' => []]; } @@ -189,7 +190,7 @@ public function searchMessages(?string $userId, string $query): array { array_map(fn(Message $m) => $m->getSessionId(), $messages) )); return [ - 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), + 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays 'sessionIds' => $sessionIds, ]; } From 6e4871f15cb81a99ac41add23a6ad653eac0403d Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Mon, 8 Jun 2026 14:49:24 +0200 Subject: [PATCH 5/8] feat(search)added message search to the UI: search input in sidebar, denounced api calls, filtered session, and a no results message. added a fix to backend: prefix message columns with table alias to avoid ambiguous id in join Signed-off-by: Anna Visman --- lib/Db/ChattyLLM/MessageMapper.php | 2 +- lib/Service/ChatService.php | 4 +- .../ChattyLLM/ChattyLLMInputForm.vue | 47 +++- tests/unit/Service/ChatServiceSearchTest.php | 206 +++++++++--------- 4 files changed, 152 insertions(+), 107 deletions(-) diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index 454fc336d..3072e70da 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -191,7 +191,7 @@ public function deleteMessageById(int $sessionId, int $messageId): void { */ public function searchMessages(string $userId, string $query, int $limit = 100): array { $qb = $this->db->getQueryBuilder(); - $qb->select(Message::$columns) + $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))) diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index 3df748316..3ad14a902 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -187,10 +187,10 @@ public function searchMessages(?string $userId, string $query): array { throw new InternalException(previous: $e); } $sessionIds = array_values(array_unique( - array_map(fn(Message $m) => $m->getSessionId(), $messages) + array_map(fn (Message $m) => $m->getSessionId(), $messages) )); return [ - 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays + 'messages' => array_map(fn (Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays 'sessionIds' => $sessionIds, ]; } 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 index 7f1514190..945533e50 100644 --- a/tests/unit/Service/ChatServiceSearchTest.php +++ b/tests/unit/Service/ChatServiceSearchTest.php @@ -25,106 +25,106 @@ class ChatServiceSearchTest extends TestCase { - private ChatService $service; - private MessageMapper $messageMapper; - - protected function setUp(): void { - parent::setUp(); - - // Mock all ChatService dependencies - $this->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 + private ChatService $service; + private MessageMapper $messageMapper; + + protected function setUp(): void { + parent::setUp(); + + // Mock all ChatService dependencies + $this->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']); + } +} From efb9192778e1987f376cb6f9bb7a8d52c6864889 Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Tue, 9 Jun 2026 15:38:49 +0200 Subject: [PATCH 6/8] ran composer run openai Signed-off-by: Anna Visman --- openapi.json | 131 ++++++++++++++++++ .../ChattyLLM/ChattyLLMInputForm.vue | 1 + src/components/ChattyLLM/ConversationBox.vue | 5 + src/components/ChattyLLM/Message.vue | 33 ++++- 4 files changed, 169 insertions(+), 1 deletion(-) diff --git a/openapi.json b/openapi.json index 201a64dea..6a160e137 100644 --- a/openapi.json +++ b/openapi.json @@ -4292,6 +4292,137 @@ } } }, + "/ocs/v2.php/apps/assistant/chat/search": { + "get": { + "operationId": "chattyllm-search-messages", + "summary": "Search chat messages", + "description": "Search through all chat messages for the current user", + "tags": [ + "chat_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "query", + "in": "query", + "description": "The search query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Search results returned successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "messages", + "sessionIds" + ], + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatMessage" + } + }, + "sessionIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Not logged in", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } + }, "/ocs/v2.php/apps/assistant/chat/generate": { "get": { "operationId": "chattyllm-generate-for-session", diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 51ef922ee..2d0b3b051 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -126,6 +126,7 @@
diff --git a/src/components/ChattyLLM/ConversationBox.vue b/src/components/ChattyLLM/ConversationBox.vue index c73e03089..3fcb7c068 100644 --- a/src/components/ChattyLLM/ConversationBox.vue +++ b/src/components/ChattyLLM/ConversationBox.vue @@ -29,6 +29,7 @@ :regenerate-loading="loading.llmGeneration && message.id === regenerateFromId" :new-message-loading="loading.newHumanMessage && idx === (messages.length - 1)" :information-source-names="informationSourceNames" + :search-query="searchQuery" @regenerate="regenerate(message.id)" @delete="deleteMessage(message.id)" /> @@ -83,6 +84,10 @@ export default { type: Boolean, default: false, }, + searchQuery: { + type: String, + default: '', + }, }, emits: ['delete', 'regenerate'], diff --git a/src/components/ChattyLLM/Message.vue b/src/components/ChattyLLM/Message.vue index 236164954..10ecf64cd 100644 --- a/src/components/ChattyLLM/Message.vue +++ b/src/components/ChattyLLM/Message.vue @@ -55,12 +55,16 @@
- +
a.type === SHAPE_TYPE_NAMES.Audio) ?? [] }, + highlightedContent() { + if (!this.searchQuery || !this.message.content) { + return this.message.content + } + const escapeHtml = (s) => s.replace(/&/g, '&').replace(//g, '>') + const regexEscaped = this.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const parts = this.message.content.split(new RegExp(`(${regexEscaped})`, 'gi')) + return parts.map((part, i) => + i % 2 === 1 ? `${escapeHtml(part)}` : escapeHtml(part), + ).join('') + }, }, mounted() { @@ -255,6 +274,18 @@ export default { :deep(.widget-default), :deep(.widget-custom) { width: auto !important; } + + &--highlighted { + white-space: pre-wrap; + word-wrap: break-word; + + :deep(mark) { + background-color: var(--color-warning); + color: var(--color-main-text); + border-radius: 2px; + padding: 0 2px; + } + } } } From a8a5ba721058accee26ef12fbc034e1bcdee7c8f Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Tue, 9 Jun 2026 15:58:13 +0200 Subject: [PATCH 7/8] feat(search): made a change to ChatService::searchMessages to pass the psalm CI check: updated the @return docblock + added a @var assertion to serializedMessages object Signed-off-by: Anna Visman --- lib/Service/ChatService.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index 3ad14a902..36ef2f188 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -169,7 +169,7 @@ public function getSessionsForUser(?string $userId): array { } /** - * @return array{messages: list>, sessionIds: list} + * @return array{messages: list}>, sessionIds: list} * @throws UnauthorizedException * @throws InternalException */ @@ -187,10 +187,12 @@ public function searchMessages(?string $userId, string $query): array { throw new InternalException(previous: $e); } $sessionIds = array_values(array_unique( - array_map(fn (Message $m) => $m->getSessionId(), $messages) + array_map(fn (Message $m) => $m->getSessionId(), $messages) // Extract the session id from each message, remove duplicates, re-index the array )); + /** @var list}> $serializedMessages */ + $serializedMessages = array_map(fn (Message $m) => $m->jsonSerialize(), $messages); return [ - 'messages' => array_map(fn (Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays + 'messages' => $serializedMessages, 'sessionIds' => $sessionIds, ]; } From a9a88a5a961d8e5f11c480ae8bde5c335a3d10cd Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Tue, 9 Jun 2026 17:43:36 +0200 Subject: [PATCH 8/8] feat(search): simple highlighting is working. the matching parts of the search query are highlighted in yellow in the conversations. added a console debug statement to ChattyLLM:Message.vue Signed-off-by: Anna Visman --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 2d0b3b051..4625940a8 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -415,6 +415,7 @@ export default { }, searchQuery(newVal) { + console.debug('[search] searchQuery changed to:', JSON.stringify(newVal)) clearTimeout(this.searchDebounceTimer) if (!newVal.trim()) { this.searchResults = null