diff --git a/appinfo/routes.php b/appinfo/routes.php index 3dc4df1d0..34fa878ae 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -52,6 +52,7 @@ ['name' => 'chattyLLM#deleteSession', 'url' => '/chat/delete_session', 'verb' => 'DELETE'], ['name' => 'chattyLLM#deleteSessions', 'url' => '/chat/delete_sessions', 'verb' => 'DELETE'], ['name' => 'chattyLLM#getSessions', 'url' => '/chat/sessions', 'verb' => 'GET'], + ['name' => 'chattyLLM#searchMessages', '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 0123d80ef..b77967737 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -365,6 +365,37 @@ public function getSessions(?bool $isAssignment = false): JSONResponse { } } + /** + * Search chat messages + * + * Get the messages of the current user's chat sessions matching the search query, most recent first + * + * @param string $query Text to search for in the message contents (case-insensitive) + * @param bool|null $isAssignment Whether to search only the sessions for assignments (true) or not (false) defaults to false + * @return JSONResponse, array{}>|JSONResponse + * + * 200: The matching messages have been obtained successfully + * 400: The search query is empty + * 401: Not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] + public function searchMessages(string $query, ?bool $isAssignment = false): JSONResponse { + try { + $messages = $this->chatService->searchMessages($this->userId, $query, $isAssignment); + return new JSONResponse(array_map(static function (Message $message) { + return $message->jsonSerialize(); + }, $messages)); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } 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); + } + } + /** * Add a message * diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index 5522f9c6f..bf0147239 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -109,6 +109,36 @@ public function getMessages(int $sessionId, int $cursor, int $limit, string $fil return array_reverse($messages); } + /** + * Find the messages of a user's sessions whose content contains the query (case-insensitive), most recent first + * + * @param string $userId + * @param string $query + * @param bool $isAssignment Whether to search in the sessions for assignments (true) or not (false) + * @param int $limit + * @return list + * @throws \OCP\DB\Exception + */ + public function searchUserMessages(string $userId, string $query, bool $isAssignment, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(array_map(static fn (string $column) => 'm.' . $column, Message::$columns)) + ->from($this->getTableName(), 'm') + ->innerJoin('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') + ->addOrderBy('m.id', 'DESC') + ->setMaxResults($limit); + + if ($isAssignment) { + $qb->andWhere($qb->expr()->isNotNull('s.assignment_id')); + } else { + $qb->andWhere($qb->expr()->isNull('s.assignment_id')); + } + + return $this->findEntities($qb); + } + /** * @param int $sessionId * @param int $maxTimestamp diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index b2be960a3..2a3fec344 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -30,6 +30,7 @@ class ChatService { public const EMPTY_CONVERSATION_TOKEN = '{}'; + public const SEARCH_MESSAGE_LIMIT = 200; public function __construct( private readonly IUserManager $userManager, @@ -226,6 +227,29 @@ public function getSessionsForUser(?string $userId, bool $isAssignment): array { } } + /** + * Find the user's messages matching the query, the SEARCH_MESSAGE_LIMIT most recent ones first + * + * @return list + * @throws BadRequestException + * @throws InternalException + * @throws UnauthorizedException + */ + public function searchMessages(?string $userId, string $query, bool $isAssignment): array { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + $query = trim($query); + if ($query === '') { + throw new BadRequestException($this->l10n->t('The search query is empty')); + } + try { + return $this->messageMapper->searchUserMessages($userId, $query, $isAssignment, self::SEARCH_MESSAGE_LIMIT); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + } + /** * @throws BadRequestException * @throws InternalException diff --git a/openapi.json b/openapi.json index 1ed2389b1..8a2f11557 100644 --- a/openapi.json +++ b/openapi.json @@ -4878,6 +4878,149 @@ } } }, + "/ocs/v2.php/apps/assistant/chat/search": { + "get": { + "operationId": "chattyllm-search-messages", + "summary": "Search chat messages", + "description": "Get the messages of the current user's chat sessions matching the search query, most recent first", + "tags": [ + "chat_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "query", + "in": "query", + "description": "Text to search for in the message contents (case-insensitive)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "isAssignment", + "in": "query", + "description": "Whether to search only the sessions for assignments (true) or not (false) defaults to false", + "schema": { + "type": "boolean", + "nullable": true, + "default": false + } + }, + { + "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": "The matching messages have been obtained successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatMessage" + } + } + } + } + }, + "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": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "The search query is empty", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/assistant/chat/new_message": { "put": { "operationId": "chattyllm-new-message", diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 12f374801..c629808ac 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -5,6 +5,10 @@ - @@ -52,11 +56,18 @@ {{ isAssignment ? t('assistant', 'Loading scheduled tasks…') : t('assistant', 'Loading conversations…') }} -
+
+ + {{ t('assistant', 'Searching messages…') }} +
+
+ {{ isAssignment ? t('assistant', 'No scheduled tasks with matching messages') : t('assistant', 'No conversations with matching messages') }} +
+
{{ isAssignment ? t('assistant', 'No scheduled tasks yet') : t('assistant', 'No conversations yet') }}
+