From 1239befc1652c34a4163a5b74ff80e1051725aaf Mon Sep 17 00:00:00 2001 From: Borislav Stefanovic Date: Fri, 27 Feb 2026 12:29:50 +0100 Subject: [PATCH 1/5] feat: add search functionality for conversations --- appinfo/routes.php | 1 + lib/Controller/ChattyLLMController.php | 33 ++++++ lib/Db/ChattyLLM/SessionMapper.php | 20 ++++ .../ChattyLLM/ChattyLLMInputForm.vue | 102 +++++++++++++++++- src/components/ChattyLLM/ConversationBox.vue | 10 ++ src/components/ChattyLLM/Message.vue | 19 +++- 6 files changed, 181 insertions(+), 4 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 1022f74c..142b7a73 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -44,6 +44,7 @@ ['name' => 'chattyLLM#updateSessionTitle', 'url' => '/chat/update_session', 'verb' => 'PATCH'], ['name' => 'chattyLLM#deleteSession', 'url' => '/chat/delete_session', 'verb' => 'DELETE'], ['name' => 'chattyLLM#getSessions', 'url' => '/chat/sessions', 'verb' => 'GET'], + ['name' => 'chattyLLM#searchChat', 'url' => '/chat/search', 'verb' => 'GET'], ['name' => 'chattyLLM#newMessage', 'url' => '/chat/new_message', 'verb' => 'PUT'], ['name' => 'chattyLLM#deleteMessage', 'url' => '/chat/delete_message', 'verb' => 'DELETE'], ['name' => 'chattyLLM#getMessages', 'url' => '/chat/messages', 'verb' => 'GET'], diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index e996cb0e..59a516cb 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -311,6 +311,39 @@ public function getSessions(): JSONResponse { } } + /** + * Search chat messages by content + * + * Returns sessions that contain matching messages. + * + * @param string $query Search query (min 2 characters) + * @return JSONResponse}, array{}>|JSONResponse + * + * 200: Search results + * 401: Not logged in + * 500: Failed to search + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] + public function searchChat(string $query = ''): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + $query = trim($query); + if (empty($query) || strlen($query) < 2) { + return new JSONResponse(['sessions' => []]); + } + + try { + $sessions = $this->sessionMapper->searchSessionsByMessageContent($this->userId, $query); + return new JSONResponse(['sessions' => $sessions]); + } catch (\OCP\DB\Exception $e) { + $this->logger->warning('Failed to search chat messages', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to search chat sessions')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + /** * Add a message * diff --git a/lib/Db/ChattyLLM/SessionMapper.php b/lib/Db/ChattyLLM/SessionMapper.php index 9006bcf6..4a167eab 100644 --- a/lib/Db/ChattyLLM/SessionMapper.php +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -185,4 +185,24 @@ public function updateSessionIsRemembered(?string $userId, int $sessionId, bool $session->setIsRemembered($is_remembered); $this->update($session); } + + /** + * @return array + * @throws \OCP\DB\Exception + */ + public function searchSessionsByMessageContent(string $userId, string $query): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('s.id', 's.user_id', 's.title', 's.timestamp', 's.agency_conversation_token', 's.agency_pending_actions', 's.summary', 's.is_summary_up_to_date', 's.is_remembered') + ->from($this->getTableName(), 's') + ->innerJoin('s', 'assistant_chat_msgs', 'm', $qb->expr()->eq('s.id', 'm.session_id')) + ->where($qb->expr()->eq('s.user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->in('m.role', $qb->createParameter('roles'))) + ->andWhere($qb->expr()->iLike('m.content', $qb->createPositionalParameter('%' . $query . '%', IQueryBuilder::PARAM_STR))) + ->groupBy('s.id') + ->orderBy('s.timestamp', 'DESC'); + + $qb->setParameter('roles', ['human', 'assistant'], IQueryBuilder::PARAM_STR_ARRAY); + + return $this->findEntities($qb); + } } diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index b6164487..a2ab11be 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -6,6 +6,17 @@
+ @@ -17,11 +28,11 @@ {{ t('assistant', 'Loading conversations…') }}
-
- {{ t('assistant', 'No conversations yet') }} +
+ {{ searchQuery ? t('assistant', 'No matching conversations') : t('assistant', 'No conversations yet') }}
@@ -203,6 +216,7 @@ import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList' import NcAppNavigationNew from '@nextcloud/vue/components/NcAppNavigationNew' import NcButton from '@nextcloud/vue/components/NcButton' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcTextField from '@nextcloud/vue/components/NcTextField' import NcDialog from '@nextcloud/vue/components/NcDialog' import ConversationBox from './ConversationBox.vue' @@ -249,6 +263,7 @@ export default { NcAppNavigationNew, NcButton, NcLoadingIcon, + NcTextField, NcDialog, ConversationBox, @@ -302,10 +317,33 @@ export default { message: t('assisant', 'Which actions can you do for me?'), }, ], + searchQuery: '', + searchResults: { sessions: [] }, + searchLoading: false, + searchDebounceTimer: null, } }, computed: { + displayedSessions() { + if (this.sessions === null) { + return null + } + if (this.searchQuery.trim().length < 2) { + return this.sessions + } + return this.searchResults.sessions + }, + matchedMessageIds() { + const q = this.searchQuery.trim() + if (q.length < 2 || !this.active || !this.messages?.length) { + return [] + } + const lower = q.toLowerCase() + return this.messages + .filter((m) => m.content && m.content.toLowerCase().includes(lower)) + .map((m) => m.id) + }, deletionConfirmationMessage() { if (this.sessions === null || this.sessionIdToDelete === null) { return '' @@ -565,6 +603,48 @@ export default { } }, + onSearchInput(value) { + this.searchQuery = value + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer) + } + if (value.trim().length < 2) { + this.searchResults = { sessions: [] } + return + } + this.searchDebounceTimer = setTimeout(() => { + this.runSearch() + }, 300) + }, + + clearSearch() { + this.searchQuery = '' + this.searchResults = { sessions: [] } + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer) + this.searchDebounceTimer = null + } + }, + + async runSearch() { + const query = this.searchQuery.trim() + if (query.length < 2) { + this.searchResults = { sessions: [] } + return + } + try { + this.searchLoading = true + const response = await axios.get(getChatURL('/search'), { params: { query } }) + this.searchResults = { sessions: response.data.sessions ?? [] } + } catch (error) { + console.error('searchChat error:', error) + this.searchResults = { sessions: [] } + showError(error?.response?.data?.error ?? t('assistant', 'Error searching conversations')) + } finally { + this.searchLoading = false + } + }, + async fetchSessions() { try { const response = await axios.get(getChatURL('/sessions')) @@ -913,6 +993,22 @@ export default { display: flex; height: 100%; + .chat-search { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0.5em; + + :deep(.input-field) { + flex: 1; + min-width: 0; + } + + &__loading { + flex-shrink: 0; + } + } + :deep(.app-navigation-new) { padding: 0; } diff --git a/src/components/ChattyLLM/ConversationBox.vue b/src/components/ChattyLLM/ConversationBox.vue index c73e0308..f1225933 100644 --- a/src/components/ChattyLLM/ConversationBox.vue +++ b/src/components/ChattyLLM/ConversationBox.vue @@ -29,6 +29,8 @@ :regenerate-loading="loading.llmGeneration && message.id === regenerateFromId" :new-message-loading="loading.newHumanMessage && idx === (messages.length - 1)" :information-source-names="informationSourceNames" + :search-query="searchQuery" + :is-search-match="matchedMessageIds && matchedMessageIds.includes(message.id)" @regenerate="regenerate(message.id)" @delete="deleteMessage(message.id)" /> @@ -83,6 +85,14 @@ export default { type: Boolean, default: false, }, + searchQuery: { + type: String, + default: '', + }, + matchedMessageIds: { + type: Array, + default: () => [], + }, }, emits: ['delete', 'regenerate'], diff --git a/src/components/ChattyLLM/Message.vue b/src/components/ChattyLLM/Message.vue index ff776294..f2255d96 100644 --- a/src/components/ChattyLLM/Message.vue +++ b/src/components/ChattyLLM/Message.vue @@ -56,7 +56,7 @@
`*****${match}*****`) + }, parsedSources() { if (!this.message.sources || ['', '[]'].includes(this.message.sources)) { return [] From 24294bb7323be11977e5ed8bf9a71e8b9b80e4b5 Mon Sep 17 00:00:00 2001 From: Borislav Stefanovic Date: Fri, 27 Feb 2026 12:30:26 +0100 Subject: [PATCH 2/5] feat: remove search term on new conversation action --- 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 a2ab11be..ca664e59 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -770,6 +770,7 @@ export default { }, async newSession(title = null) { + this.clearSearch() try { this.loading.newSession = true const newSessionResponse = await axios.put(getChatURL('/new_session'), { From f35bb10f8531760c3c2a56c4e0f53e8ef44c1a5f Mon Sep 17 00:00:00 2001 From: Borislav Stefanovic Date: Fri, 27 Feb 2026 12:31:13 +0100 Subject: [PATCH 3/5] feat: added navigation between search matches --- .../ChattyLLM/ChattyLLMInputForm.vue | 140 +++++++++++++++++- src/components/ChattyLLM/ConversationBox.vue | 10 +- src/components/ChattyLLM/Message.vue | 28 +++- 3 files changed, 172 insertions(+), 6 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index ca664e59..65fb4044 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -96,7 +96,7 @@
-
+
@@ -133,8 +133,10 @@ :slow-pickup="slowPickup" :search-query="searchQuery" :matched-message-ids="matchedMessageIds" + :highlighted-message-index="highlightedMessageIndex" @regenerate="runRegenerationTask" - @delete="deleteMessage" /> + @delete="deleteMessage" + @highlight-end="highlightedMessageIndex = null" />
+
+ {{ t('assistant', 'Search matches') }} + {{ searchMatchCurrentDisplay }}/{{ matchingMessageIndices.length }} +
+ + + + + + +
+

{{ t('assistant', 'Output shown here is generated by AI. Make sure to always double-check.') }}

@@ -199,6 +225,8 @@