diff --git a/appinfo/info.xml b/appinfo/info.xml index 61eee98f..b1ad4441 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -62,7 +62,7 @@ Known providers: More details on how to set this up in the [admin docs](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html) ]]> - 3.4.1 + 3.5.0-dev.1 agpl Julien Veyssier Assistant diff --git a/composer.json b/composer.json index 0b4f1448..6c9d816e 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "html2text/html2text": "^4.3", "phpoffice/phpword": "^1.2", "ralouphie/mimey": "^1.0", + "simshaun/recurr": "^5.0", "smalot/pdfparser": "^2.11" }, "scripts": { diff --git a/composer.lock b/composer.lock index 0c7e6a5f..2cfb9b18 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,142 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1044674e3dc6cc194dc15097e173000b", + "content-hash": "8b9a5743024889b768ae6fc5144429d8", "packages": [ + { + "name": "doctrine/collections", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-json": "*", + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.6.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-01-15T10:01:58+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "erusev/parsedown", "version": "1.8.0", @@ -351,6 +485,65 @@ }, "time": "2016-09-28T03:36:23+00:00" }, + { + "name": "simshaun/recurr", + "version": "v5.0.3", + "source": { + "type": "git", + "url": "https://github.com/simshaun/recurr.git", + "reference": "7b136768d64f257065e38a804ee6d2f9af6ba6d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simshaun/recurr/zipball/7b136768d64f257065e38a804ee6d2f9af6ba6d1", + "reference": "7b136768d64f257065e38a804ee6d2f9af6ba6d1", + "shasum": "" + }, + "require": { + "doctrine/collections": "~1.6||^2.0", + "php": "^7.2||^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.16", + "symfony/yaml": "^5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Recurr\\": "src/Recurr/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Shaun Simmons", + "email": "shaun@shaun.pub", + "homepage": "https://shaun.pub" + } + ], + "description": "PHP library for working with recurrence rules", + "homepage": "https://github.com/simshaun/recurr", + "keywords": [ + "dates", + "events", + "recurrence", + "recurring", + "rrule" + ], + "support": { + "issues": "https://github.com/simshaun/recurr/issues", + "source": "https://github.com/simshaun/recurr/tree/v5.0.3" + }, + "time": "2024-12-12T15:39:24+00:00" + }, { "name": "smalot/pdfparser", "version": "v2.12.4", @@ -486,6 +679,86 @@ } ], "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T18:47:49+00:00" } ], "packages-dev": [ diff --git a/lib/BackgroundJob/RunAssignmentsJob.php b/lib/BackgroundJob/RunAssignmentsJob.php new file mode 100644 index 00000000..3d0656a2 --- /dev/null +++ b/lib/BackgroundJob/RunAssignmentsJob.php @@ -0,0 +1,37 @@ +setAllowParallelRuns(true); + $this->setTimeSensitivity(self::TIME_SENSITIVE); + $this->setInterval(60 * 10); // 10min + } + public function run($argument) { + $userId = $argument['userId']; + try { + $this->assignmentService->runDueAssignmentsForUser($userId); + } catch (InternalException $e) { + $this->logger->error('Error running assignments for user ' . $userId, ['exception' => $e]); + } + } +} diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php new file mode 100644 index 00000000..d225c249 --- /dev/null +++ b/lib/Controller/AssignmentsApiController.php @@ -0,0 +1,210 @@ +|DataResponse + * + * 200: User assignments returned + * 403: User not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'POST', url: '/assignments')] + public function createUserAssignment(string $prompt, int $startsAt, string $recurrence): DataResponse { + try { + $assignment = $this->assignmentsService->createAssignment($this->userId, $prompt, $startsAt, $recurrence); + $serializedAssignment = $assignment->jsonSerialize(); + return new DataResponse(['assignment' => $serializedAssignment]); + } catch (InternalException $e) { + $this->logger->error('Error while fetching assignments for user ' . $this->userId, ['exception' => $e]); + return new DataResponse('', Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (UnauthorizedException $e) { + return new DataResponse('', Http::STATUS_FORBIDDEN); + } + } + + /** + * Get user's assignments + * + * Get a list of assignmetns for the current user. + * + * @return DataResponse}, array{}>|DataResponse + * + * 200: User assignments returned + * 403: User not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'GET', url: '/assignments')] + public function getUserAssignments(): DataResponse { + if ($this->userId !== null) { + try { + $assignments = iterator_to_array($this->assignmentMapper->findForUser($this->userId)); + /** @var list $serializedAssignments */ + $serializedAssignments = array_map(static function (Assignment $assignments) { + return $assignments->jsonSerialize(); + }, $assignments); + return new DataResponse(['assignments' => $serializedAssignments]); + } catch (Exception $e) { + $this->logger->error('Error while fetching assignments for user ' . $this->userId, ['exception' => $e]); + return new DataResponse(['assignments' => []]); + } + } + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } + + /** + * Get user's assignment + * + * @param int $id The id of the assignment to return + * + * @return DataResponse|DataResponse + * + * 200: User tasks returned + * 403: User not logged in + * 404: Assignment not found + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'GET', url: '/assignments/{id}')] + public function getUserAssignment(int $id): DataResponse { + if ($this->userId !== null) { + try { + $assignment = $this->assignmentMapper->find($this->userId, $id); + /** @var AssistantAssignment $serializedAssignment */ + $serializedAssignment = $assignment->jsonSerialize(); + return new DataResponse(['assignment' => $serializedAssignment]); + } catch (Exception $e) { + $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + return new DataResponse('', HTTP::STATUS_NOT_FOUND); + } + } + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } + + /** + * Update a user's assignment + * + * @param int $id The id of the assignment + * @param string|null $prompt The prompt to be sent to the assistant when the assignment is executed + * @param int|null $startsAt The timestamp when the assignment should start being executed + * @param string|null $recurrence The recurrence rule for the assignment, in RRULE format + * + * @return DataResponse|DataResponse + * + * 200: User tasks returned + * 403: User not logged in + * 400: Malformed recurrence rule + * 404: Assignment not found + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'PATCH', url: '/assignments/{id}')] + public function updateUserAssignment(int $id, ?string $prompt, ?string $recurrence, ?int $startsAt): DataResponse { + if ($this->userId !== null) { + try { + $assignment = $this->assignmentMapper->find($this->userId, $id); + if ($prompt !== null) { + $assignment->setPrompt($prompt); + } + if ($recurrence !== null) { + try { + $assignment->setRecurrence($recurrence); + } catch (\InvalidArgumentException $e) { + return new DataResponse('', HTTP::STATUS_BAD_REQUEST); + } + } + if ($startsAt !== null) { + $assignment->setStartsAt($startsAt); + } + $assignment->setUpdatedAt($this->timeFactory->now()->getTimestamp()); + $this->assignmentMapper->update($assignment); + /** @var AssistantAssignment $serializedAssignment */ + $serializedAssignment = $assignment->jsonSerialize(); + return new DataResponse(['assignment' => $serializedAssignment]); + } catch (Exception $e) { + $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + return new DataResponse('', HTTP::STATUS_NOT_FOUND); + } + } + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } + + /** + * Delete a user's assignment + * + * @param int $id The id of the assignment to delete + * @return DataResponse|DataResponse + * + * 200: User assignment deleted or not found + * 403: User not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'DELETE', url: '/assignments/{id}')] + public function deleteUserAssignment(int $id): DataResponse { + if ($this->userId !== null) { + try { + $assignment = $this->assignmentMapper->find($this->userId, $id); + $this->assignmentMapper->delete($assignment); + return new DataResponse('', HTTP::STATUS_OK); + } catch (Exception $e) { + $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + // 200 OK because of idempotence, if we send DELETE twice, we return the same response twice + return new DataResponse('', HTTP::STATUS_OK); + } + } + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } +} diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 8758e4dc..72a976f6 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -10,10 +10,11 @@ use OCA\Assistant\AppInfo\Application; use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\MessageMapper; -use OCA\Assistant\Db\ChattyLLM\Session; use OCA\Assistant\Db\ChattyLLM\SessionMapper; use OCA\Assistant\ResponseDefinitions; -use OCA\Assistant\Service\SessionSummaryService; +use OCA\Assistant\Service\BadRequestException; +use OCA\Assistant\Service\ChatService; +use OCA\Assistant\Service\InternalException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Http; @@ -26,14 +27,9 @@ use OCP\IL10N; use OCP\IRequest; use OCP\IUserManager; -use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\NotFoundException; -use OCP\TaskProcessing\Exception\PreConditionNotMetException; -use OCP\TaskProcessing\Exception\UnauthorizedException; -use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager as ITaskProcessingManager; use OCP\TaskProcessing\Task; -use OCP\TaskProcessing\TaskTypes\TextToTextChat; use Psr\Log\LoggerInterface; /** @@ -56,7 +52,7 @@ public function __construct( private IAppConfig $appConfig, private IUserManager $userManager, private ?string $userId, - private SessionSummaryService $sessionSummaryService, + private ChatService $chatService, ) { parent::__construct($appName, $request); $this->agencyActionData = [ @@ -213,50 +209,16 @@ private function improveAgencyActionNames(array $actions): array { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function newSession(int $timestamp, ?string $title = null): JSONResponse { - if ($timestamp > 10_000_000_000) { - $timestamp = intdiv($timestamp, 1000); - } - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userManager->get($this->userId); - if ($user === null) { - return new JSONResponse(['error' => $this->l10n->t('User not found')], Http::STATUS_UNAUTHORIZED); - } - - $userInstructions = $this->appConfig->getValueString( - Application::APP_ID, - 'chat_user_instructions', - Application::CHAT_USER_INSTRUCTIONS, - lazy: true, - ) ?: Application::CHAT_USER_INSTRUCTIONS; - $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); - try { - $session = new Session(); - $session->setUserId($this->userId); - $session->setTitle($title); - $session->setTimestamp($timestamp); - $session->setAgencyConversationToken(null); - $session->setAgencyPendingActions(null); - $this->sessionMapper->insert($session); - - $systemMsg = new Message(); - $systemMsg->setSessionId($session->getId()); - $systemMsg->setRole('system'); - $systemMsg->setAttachments('[]'); - $systemMsg->setContent($userInstructions); - $systemMsg->setTimestamp($session->getTimestamp()); - $systemMsg->setSources('[]'); - $this->messageMapper->insert($systemMsg); - + $session = $this->chatService->createChatSession($this->userId, $timestamp, $title); return new JSONResponse([ 'session' => $session->jsonSerialize(), ]); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to create a chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to create a chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('Unauthorized')], Http::STATUS_UNAUTHORIZED); } } @@ -267,24 +229,25 @@ public function newSession(int $timestamp, ?string $title = null): JSONResponse * * @param integer $sessionId The chat session ID * @param string $title The new chat session title - * @return JSONResponse|JSONResponse + * @return JSONResponse|JSONResponse * * 200: The title has been updated successfully + * 404: Session not found * 401: Not logged in */ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function updateSessionTitle(int $sessionId, string $title): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { - $this->sessionMapper->updateSessionTitle($this->userId, $sessionId, $title); + $this->chatService->updateSession($this->userId, $sessionId, $title); return new JSONResponse(); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to update the chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to update the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('Unauthorized')], Http::STATUS_UNAUTHORIZED); } } @@ -302,31 +265,13 @@ public function updateSessionTitle(int $sessionId, string $title): JSONResponse #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function updateChatSession(int $sessionId, ?string $title = null, ?bool $is_remembered = null): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND); - } - if ($title === null && $is_remembered === null) { - return new JSONResponse(); - } - try { - $session = $this->sessionMapper->getUserSession($this->userId, $sessionId); - if ($title !== null) { - $session->setTitle($title); - } - if ($is_remembered !== null) { - $session->setIsRemembered($is_remembered); - // schedule summarizer jobs for this chat user - if ($is_remembered) { - $this->sessionSummaryService->scheduleJobsForUser($this->userId); - } - } - $this->sessionMapper->update($session); + $this->chatService->updateSession($this->userId, $sessionId, $title, $is_remembered); return new JSONResponse(); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to update the chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to update the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + } catch (\OCA\Assistant\Service\NotFoundException|\OCA\Assistant\Service\UnauthorizedException $e) { return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND); } } @@ -345,18 +290,15 @@ public function updateChatSession(int $sessionId, ?string $title = null, ?bool $ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function deleteSession(int $sessionId): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { // we don't delete the tasks - $this->sessionMapper->deleteSession($this->userId, $sessionId); - $this->messageMapper->deleteMessagesBySession($sessionId); + $this->chatService->deleteSession($this->userId, $sessionId); return new JSONResponse(); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to delete the chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to delete the chat session')], 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); } } @@ -373,16 +315,15 @@ public function deleteSession(int $sessionId): JSONResponse { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function getSessions(): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { - $sessions = $this->sessionMapper->getUserSessions($this->userId); + /** @var list $sessions */ + $sessions = $this->chatService->getSessionsForUser($this->userId); return new JSONResponse($sessions); - } catch (\OCP\DB\Exception $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to get chat sessions', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to get chat sessions')], 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); } } @@ -409,61 +350,18 @@ public function getSessions(): JSONResponse { public function newMessage( int $sessionId, string $role, string $content, int $timestamp, ?array $attachments = null, bool $firstHumanMessage = false, ): JSONResponse { - if ($timestamp > 10_000_000_000) { - $timestamp = intdiv($timestamp, 1000); - } - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - if (strlen($content) > Application::MAX_TEXT_INPUT_LENGTH) { - return new JSONResponse(['error' => $this->l10n->t('The new message is too long')], Http::STATUS_BAD_REQUEST); - } - try { - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - // refuse empty text content if context agent is not available (we do classic chat) AND there is no attachment - // in other words: accept empty content if we are using agency OR there are attachments - $content = trim($content); - if (empty($content) - && (!class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentInteraction') - || !isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID])) - && $attachments === null - ) { - return new JSONResponse(['error' => $this->l10n->t('Message content is empty')], Http::STATUS_BAD_REQUEST); - } - - $message = new Message(); - $message->setSessionId($sessionId); - $message->setRole($role); - $message->setContent($content); - $message->setTimestamp($timestamp); - $message->setSources('[]'); - $message->setAttachments('[]'); - if ($attachments !== null) { - $encodedAttachments = json_encode($attachments); - if ($encodedAttachments !== false) { - $message->setAttachments($encodedAttachments); - } - } - $this->messageMapper->insert($message); - - if ($firstHumanMessage) { - // set the title of the session based on first human message - $this->sessionMapper->updateSessionTitle( - $this->userId, - $sessionId, - strlen($content) > 140 ? mb_substr($content, 0, 140) . '...' : $content, - ); - } - + $message = $this->chatService->createMessage($this->userId, $sessionId, $role, $content, $timestamp, $attachments, $firstHumanMessage); return new JSONResponse($message->jsonSerialize()); - } catch (\OCP\DB\Exception $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to add a chat message', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to add a chat message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -484,28 +382,18 @@ public function newMessage( #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function getMessages(int $sessionId, int $limit = 20, int $cursor = 0): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - /** @var list $messages */ - $messages = $this->messageMapper->getMessages($sessionId, $cursor, $limit); - if ($messages[0]->getRole() === 'system') { - array_shift($messages); - } - + $messages = $this->chatService->getSessionMessages($this->userId, $sessionId, $limit, $cursor); return new JSONResponse(array_map(static function (Message $message) { return $message->jsonSerialize(); }, $messages)); - } catch (\OCP\DB\Exception $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to get chat messages', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to get chat messages')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -525,22 +413,16 @@ public function getMessages(int $sessionId, int $limit = 20, int $cursor = 0): J #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function getMessage(int $sessionId, int $messageId): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - $message = $this->messageMapper->getMessageById($sessionId, $messageId); - + $message = $this->chatService->getSessionMessage($this->userId, $sessionId, $messageId); return new JSONResponse($message->jsonSerialize()); - } catch (\OCP\DB\Exception $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to get chat messages', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to get chat message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -565,19 +447,15 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { } try { - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - $message = $this->messageMapper->getMessageById($sessionId, $messageId); - - // do not delete the related task - $this->messageMapper->deleteMessageById($sessionId, $messageId); - + $this->chatService->deleteSessionMessage($this->userId, $sessionId, $messageId); return new JSONResponse(); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to delete a chat message', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to delete a chat message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -602,129 +480,22 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function generateForSession(int $sessionId, int $agencyConfirm = 0): JSONResponse { - if ($this->userId === null) { + try { + $taskId = $this->chatService->scheduleMessageGeneration($this->userId, $sessionId, $agencyConfirm); + } catch (InternalException $e) { + $this->logger->warning('Failed to schedule message generation', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to schedule message generation')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - if (class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentInteraction') - && isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]) - ) { - $lastUserMessage = $this->messageMapper->getLastHumanMessage($sessionId); - $session = $this->sessionMapper->getUserSession($this->userId, $sessionId); - $lastConversationToken = $session->getAgencyConversationToken() ?? '{}'; - - $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; - $audioAttachment = $lastAttachments[0] ?? null; - // see https://github.com/vimeo/psalm/issues/7980 - $isContextAgentAudioAvailable = false; - if (class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentAudioInteraction')) { - $isContextAgentAudioAvailable = isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]); - } - if ($audioAttachment !== null - && isset($audioAttachment['type']) - && $audioAttachment['type'] === 'Audio' - && $isContextAgentAudioAvailable - ) { - // audio agency - $fileId = $audioAttachment['file_id']; - try { - $taskId = $this->scheduleAgencyAudioTask($fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId()); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - } else { - // classic agency - $prompt = $lastUserMessage->getContent(); - try { - $taskId = $this->scheduleAgencyTask($prompt, $agencyConfirm, $lastConversationToken, $sessionId); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - } - } else { - // classic chat - $systemPrompt = ''; - $firstMessage = $this->messageMapper->getFirstNMessages($sessionId, 1); - if ($firstMessage->getRole() === 'system') { - $systemPrompt = $firstMessage->getContent(); - } - $history = $this->getRawLastMessages($sessionId); - do { - $lastUserMessage = array_pop($history); - } while ($lastUserMessage->getRole() !== 'human'); - - $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; - $audioAttachment = $lastAttachments[0] ?? null; - $isAudioToAudioAvailable = false; - if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')) { - $isAudioToAudioAvailable = isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]); - } - if ($audioAttachment !== null - && isset($audioAttachment['type']) - && $audioAttachment['type'] === 'Audio' - && $isAudioToAudioAvailable - ) { - // for an audio chat task, let's try to get the remote audio IDs for all the previous audio messages - $history = $this->getAudioHistory($history); - $fileId = $audioAttachment['file_id']; - try { - $taskId = $this->scheduleAudioChatTask($fileId, $systemPrompt, $history, $sessionId, $lastUserMessage->getId()); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - } else { - // for a text chat task, let's only use text in the history - $history = array_map(static function (Message $message) { - return json_encode([ - 'role' => $message->getRole(), - 'content' => $message->getContent(), - ]); - }, $history); - try { - $taskId = $this->scheduleLLMChatTask($lastUserMessage->getContent(), $systemPrompt, $history, $sessionId); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - } - } - return new JSONResponse(['taskId' => $taskId]); } - private function getAudioHistory(array $history): array { - // history is a list of JSON strings - // The content is the remote audio ID (or the transcription as fallback) - // We only use the audio ID for assistant messages, if we have one and if it's not expired - // The audio ID is found in integration_openai's AudioToAudioChat response for example - // It is an optional output of AudioToAudioChat tasks - return array_map(static function (Message $message) { - $entry = [ - 'role' => $message->getRole(), - ]; - $attachments = $message->jsonSerialize()['attachments']; - if ($message->getRole() === 'assistant' - && count($attachments) > 0 - && $attachments[0]['type'] === 'Audio' - && isset($attachments[0]['remote_audio_id']) - ) { - if (!isset($attachments[0]['remote_audio_expires_at']) - || time() < $attachments[0]['remote_audio_expires_at'] - ) { - $entry['audio'] = ['id' => $attachments[0]['remote_audio_id']]; - return json_encode($entry); - } - } - - $entry['content'] = $message->getContent(); - return json_encode($entry); - }, $history); - } - /** * Regenerate response for a message * @@ -747,26 +518,20 @@ private function getAudioHistory(array $history): array { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function regenerateForSession(int $sessionId, int $messageId): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - $message = $this->messageMapper->getMessageById($sessionId, $messageId); - - // we don't delete the related task try { - $this->messageMapper->deleteMessageById($sessionId, $messageId); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + $this->chatService->deleteSessionMessage($this->userId, $sessionId, $messageId); + $taskId = $this->chatService->scheduleMessageGeneration($this->userId, $sessionId, 0); + return new JSONResponse(['taskId' => $taskId]); + } catch (InternalException $e) { $this->logger->warning('Failed to delete the last message', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to delete the last message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } - - return $this->generateForSession($sessionId); } /** @@ -908,10 +673,6 @@ public function checkSession(int $sessionId): JSONResponse { * * @param integer $sessionId The chat session ID * @return JSONResponse|JSONResponse - * @throws AppConfigTypeConflictException - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - * @throws \OCP\DB\Exception * * 200: The task has been successfully scheduled * 401: Not logged in @@ -921,47 +682,18 @@ public function checkSession(int $sessionId): JSONResponse { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function generateTitle(int $sessionId): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userManager->get($this->userId); - if ($user === null) { - return new JSONResponse(['error' => $this->l10n->t('User not found')], Http::STATUS_UNAUTHORIZED); - } - - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - try { - $userInstructions = $this->appConfig->getValueString( - Application::APP_ID, - 'chat_user_instructions_title', - Application::CHAT_USER_INSTRUCTIONS_TITLE, - lazy: true, - ) ?: Application::CHAT_USER_INSTRUCTIONS_TITLE; - $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); - - $history = $this->getRawLastMessages($sessionId); - // history is a list of JSON strings - $history = array_map(static function (Message $message) { - return json_encode([ - 'role' => $message->getRole(), - 'content' => $message->getContent(), - ], JSON_THROW_ON_ERROR); - }, $history); - - try { - $taskId = $this->scheduleLLMChatTask($userInstructions, $userInstructions, $history, $sessionId, false); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } + $taskId = $this->chatService->scheduleTitleGeneration($this->userId, $sessionId); return new JSONResponse(['taskId' => $taskId]); - } catch (\OCP\DB\Exception|\JsonException $e) { + } catch (InternalException|\JsonException $e) { $this->logger->warning('Failed to generate a title for the chat session', ['exception' => $e]); - return new JSONResponse(['error' => $this->l10n->t('Failed to generate a title for the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + return new JSONResponse(['error' => $this->l10n->t('Failed to delete the last message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } @@ -1035,160 +767,4 @@ public function checkTitleGenerationTask(int $taskId, int $sessionId): JSONRespo } return new JSONResponse(['error' => 'unknown_error', 'task_status' => $task->getstatus()], Http::STATUS_BAD_REQUEST); } - - /** - * Get the last N messages (assistant and user messages, avoid initial system prompt) as an array - * - * @param integer $sessionId - * @return array - * @throws AppConfigTypeConflictException - * @throws \OCP\DB\Exception - */ - private function getRawLastMessages(int $sessionId): array { - $lastNMessages = intval($this->appConfig->getValueString(Application::APP_ID, 'chat_last_n_messages', '10', lazy: true)); - $messages = $this->messageMapper->getMessages($sessionId, 0, $lastNMessages); - - if ($messages[0]->getRole() === 'system') { - array_shift($messages); - } - return $messages; - } - - private function checkIfSessionIsThinking(string $customId): void { - try { - $tasks = $this->taskProcessingManager->getUserTasksByApp($this->userId, Application::APP_ID . ':chatty-llm', $customId); - } catch (\OCP\TaskProcessing\Exception\Exception $e) { - throw new \Exception('task_query_failed'); - } - $tasks = array_filter($tasks, static function (Task $task) { - return $task->getStatus() === Task::STATUS_RUNNING || $task->getStatus() === Task::STATUS_SCHEDULED; - }); - // prevent scheduling multiple llm tasks simultaneously for one session - if (!empty($tasks)) { - throw new \Exception('session_already_thinking'); - } - } - - /** - * Schedule the LLM task - * - * @param string $newPrompt - * @param string $systemPrompt - * @param array $history - * @param int $sessionId - * @param bool $isMessage whether we want to generate a message or a session title - * @return int - * @throws Exception - * @throws PreConditionNotMetException - * @throws UnauthorizedException - * @throws ValidationException - */ - private function scheduleLLMChatTask( - string $newPrompt, string $systemPrompt, array $history, int $sessionId, bool $isMessage = true, - ): int { - $customId = ($isMessage - ? 'chatty-llm:' - : 'chatty-title:') . $sessionId; - $this->checkIfSessionIsThinking($customId); - $input = [ - 'input' => $newPrompt, - 'system_prompt' => $systemPrompt, - 'history' => $history, - ]; - if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextChat::ID]['optionalInputShape']['memories'])) { - $input['memories'] = $this->sessionSummaryService->getMemories($this->userId); - } - $task = new Task(TextToTextChat::ID, $input, Application::APP_ID . ':chatty-llm', $this->userId, $customId); - $this->taskProcessingManager->scheduleTask($task); - return $task->getId() ?? 0; - } - - /** - * Schedule an agency task - * - * @param string $content - * @param int $confirmation - * @param string $conversationToken - * @param int $sessionId - * @return int - * @throws Exception - * @throws PreConditionNotMetException - * @throws UnauthorizedException - * @throws ValidationException - */ - private function scheduleAgencyTask(string $content, int $confirmation, string $conversationToken, int $sessionId): int { - $customId = 'chatty-llm:' . $sessionId; - $this->checkIfSessionIsThinking($customId); - $taskInput = [ - 'input' => $content, - 'confirmation' => $confirmation, - 'conversation_token' => $conversationToken, - ]; - /** @psalm-suppress UndefinedClass */ - if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]['optionalInputShape']['memories'])) { - $taskInput['memories'] = $this->sessionSummaryService->getMemories($this->userId); - } - /** @psalm-suppress UndefinedClass */ - $task = new Task( - \OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID, - $taskInput, - Application::APP_ID . ':chatty-llm', - $this->userId, - $customId - ); - $this->taskProcessingManager->scheduleTask($task); - return $task->getId() ?? 0; - } - - private function scheduleAudioChatTask( - int $audioFileId, string $systemPrompt, array $history, int $sessionId, int $queryMessageId, - ): int { - $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; - $this->checkIfSessionIsThinking($customId); - $input = [ - 'input' => $audioFileId, - 'system_prompt' => $systemPrompt, - 'history' => $history, - ]; - /** @psalm-suppress UndefinedClass */ - if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]['optionalInputShape']['memories'])) { - $input['memories'] = $this->sessionSummaryService->getMemories($this->userId); - } - /** @psalm-suppress UndefinedClass */ - $task = new Task( - \OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID, - $input, - Application::APP_ID . ':chatty-llm', - $this->userId, - $customId, - ); - $this->taskProcessingManager->scheduleTask($task); - return $task->getId() ?? 0; - } - - private function scheduleAgencyAudioTask( - int $audioFileId, int $confirmation, string $conversationToken, int $sessionId, int $queryMessageId, - ): int { - $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; - $this->checkIfSessionIsThinking($customId); - $taskInput = [ - 'input' => $audioFileId, - 'confirmation' => $confirmation, - 'conversation_token' => $conversationToken, - ]; - /** @psalm-suppress UndefinedClass */ - if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]['optionalInputShape']['memories'])) { - $taskInput['memories'] = $this->sessionSummaryService->getMemories($this->userId); - } - /** @psalm-suppress UndefinedClass */ - $task = new Task( - \OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID, - $taskInput, - Application::APP_ID . ':chatty-llm', - $this->userId, - $customId - ); - $this->taskProcessingManager->scheduleTask($task); - return $task->getId() ?? 0; - } } diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php new file mode 100644 index 00000000..9a199473 --- /dev/null +++ b/lib/Db/Assignment.php @@ -0,0 +1,136 @@ +addType('userId', Types::STRING); + $this->addType('prompt', Types::STRING); + $this->addType('recurrence', Types::STRING); + $this->addType('startsAt', Types::INTEGER); + $this->addType('createdAt', Types::STRING); + $this->addType('updatedAt', Types::STRING); + $this->addType('lastRunAt', Types::INTEGER); + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return [ + 'id' => $this->getId(), + 'user_id' => $this->getUserId(), + 'prompt' => $this->getPrompt(), + 'recurrence' => $this->getRecurrence(), + 'starts_at' => $this->getStartsAt(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + 'last_run_at' => $this->getLastRunAt(), + ]; + } + + /** + * @throws \InvalidArgumentException + */ + public function setRecurrence(string $recurrence): void { + try { + new Rule($recurrence); + } catch (InvalidRRule $e) { + throw new \InvalidArgumentException('Invalid recurrence rule: ' . $recurrence, previous: $e); + } + $this->setter('recurrence', [$recurrence]); + } + + /** + * Evaluates the recurrence rule and checks if a run is due + */ + public function isDueToRun(\DateTimeImmutable $now): bool { + try { + $startsAt = new \DateTime('@' . $this->getStartsAt()); + $lastRunAt = new \DateTime('@' . $this->getLastRunAt()); + // Find recurrences after the last run or after the current time if this assignment has never run + $rule = new Rule($this->getRecurrence(), $startsAt); + $transformer = new \Recurr\Transformer\ArrayTransformer(); + $constraint = new AfterConstraint($this->getLastRunAt() !== 0 ? $lastRunAt : $startsAt, false); + /** @var RecurrenceCollection $collection */ + $collection = $transformer->transform($rule, $constraint); + if ($collection->isEmpty()) { + return false; + } + $nextRecurrence = $collection->first(); + $isDue = $nextRecurrence->getStart()->getTimestamp() <= $now->getTimestamp() && $nextRecurrence->getStart()->getTimestamp() > $this->getLastRunAt(); + logger('assistant')->debug('Next recurrence of assignment ' . $this->getId() . ' of user ' . $this->getUserId() . ': ' . $nextRecurrence->getStart()->format('Y-m-d H:i:s') . ' - isDue: ' . ($isDue ? 'true' : 'false')); + return $isDue; + } catch (InvalidRRule|\Exception|NotFoundExceptionInterface|ContainerExceptionInterface $e) { + // this should not happen, as we validate the rule on setRecurrence, but just in case, we catch the exception and log it + logger('assistant')->error($e->getMessage(), ['exception' => $e]); + } + return false; + } +} diff --git a/lib/Db/AssignmentMapper.php b/lib/Db/AssignmentMapper.php new file mode 100644 index 00000000..f4746254 --- /dev/null +++ b/lib/Db/AssignmentMapper.php @@ -0,0 +1,89 @@ + + */ +class AssignmentMapper extends QBMapper { + public function __construct( + IDBConnection $db, + private ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'assistant_assignments', Assignment::class); + } + + /** + * @throws \OCP\DB\Exception + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @return Assignment + */ + public function find(string $userId, int $assignmentId): Assignment { + $qb = $this->db->getQueryBuilder(); + $qb->select(Assignment::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($assignmentId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))); + + return $this->findEntity($qb); + } + + /** + * @return boolean + * @throws \OCP\DB\Exception + */ + public function exists(string $userId, int $assignmentId): bool { + try { + return (bool)$this->find($userId, $assignmentId); + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + return false; + } catch (\OCP\AppFramework\Db\MultipleObjectsReturnedException $e) { + return true; + } + } + + /** + * @return \Generator + * @throws \OCP\DB\Exception + */ + public function findForUser(string $userId): \Generator { + $qb = $this->db->getQueryBuilder(); + $qb->select(Assignment::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->orderBy('created_at', 'DESC'); + + yield from $this->yieldEntities($qb); + } + + /** + * @return \Generator + * @throws \OCP\DB\Exception + */ + public function findDueAssignmentsForUser(string $userId): \Generator { + foreach ($this->findForUser($userId) as $assignment) { + if ($assignment === null) { + continue; + } + if (!$assignment->isDueToRun($this->timeFactory->now())) { + continue; + } + yield $assignment; + } + } +} diff --git a/lib/Db/ChattyLLM/Message.php b/lib/Db/ChattyLLM/Message.php index b60252bb..2cc97727 100644 --- a/lib/Db/ChattyLLM/Message.php +++ b/lib/Db/ChattyLLM/Message.php @@ -29,6 +29,10 @@ * @method \void setAttachments(?string $attachments) */ class Message extends Entity implements \JsonSerializable { + public const ROLE_HUMAN = 'human'; + + public const ROLE_ASSISTANT = 'assistant'; + /** @var int */ protected $sessionId; /** @var string */ diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index 44ff6192..a0c4c0ce 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -56,7 +56,7 @@ public function getLastHumanMessage(int $sessionId): Message { $qb->select(Message::$columns) ->from($this->getTableName()) ->where($qb->expr()->eq('session_id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter('human', IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter(Message::ROLE_HUMAN, IQueryBuilder::PARAM_STR))) ->orderBy('timestamp', 'DESC') ->setMaxResults(1); @@ -68,7 +68,7 @@ public function getLastNonEmptyHumanMessage(int $sessionId): Message { $qb->select(Message::$columns) ->from($this->getTableName()) ->where($qb->expr()->eq('session_id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter('human', IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter(Message::ROLE_HUMAN, IQueryBuilder::PARAM_STR))) ->andWhere($qb->expr()->nonEmptyString('content')) ->orderBy('timestamp', 'DESC') ->setMaxResults(1); @@ -80,7 +80,7 @@ public function getLastNonEmptyHumanMessage(int $sessionId): Message { * @param int $sessionId * @param int $cursor * @param int $limit - * @return array + * @return list * @throws \OCP\DB\Exception */ public function getMessages(int $sessionId, int $cursor, int $limit): array { diff --git a/lib/Db/ChattyLLM/Session.php b/lib/Db/ChattyLLM/Session.php index fad23840..7aafd090 100644 --- a/lib/Db/ChattyLLM/Session.php +++ b/lib/Db/ChattyLLM/Session.php @@ -25,6 +25,8 @@ * @method \void setAgencyConversationToken(?string $agencyConversationToken) * @method \string|null getAgencyPendingActions() * @method \void setAgencyPendingActions(?string $agencyPendingActions) + * @method \void setAssignmentId(int $id) + * @method \int getAssignmentId() */ class Session extends Entity implements \JsonSerializable { /** @var string */ @@ -55,6 +57,12 @@ class Session extends Entity implements \JsonSerializable { */ protected $isRemembered; + /** + * Session can be linked to assignments that run in this session + * @var ?int + */ + protected $assignmentId; + public static $columns = [ 'id', @@ -66,6 +74,7 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'is_summary_up_to_date', 'is_remembered', + 'assignment_id' ]; public static $fields = [ 'id', @@ -77,6 +86,7 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'isSummaryUpToDate', 'isRemembered', + 'assignmentId' ]; public function __construct() { @@ -88,6 +98,7 @@ public function __construct() { $this->addType('summary', Types::TEXT); $this->addType('isSummaryUpToDate', Types::SMALLINT); $this->addType('isRemembered', Types::SMALLINT); + $this->addType('assignmentId', Types::BIGINT); } #[\ReturnTypeWillChange] @@ -102,6 +113,7 @@ public function jsonSerialize() { 'summary' => $this->getSummary(), 'is_summary_up_to_date' => $this->getIsSummaryUpToDate(), 'is_remembered' => $this->getIsRemembered(), + 'assignment_id' => $this->getAssignmentId(), ]; } diff --git a/lib/Db/ChattyLLM/SessionMapper.php b/lib/Db/ChattyLLM/SessionMapper.php index 9006bcf6..babf812c 100644 --- a/lib/Db/ChattyLLM/SessionMapper.php +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -66,7 +66,25 @@ public function getUserSession(string $userId, int $sessionId): Session { /** * @param string $userId - * @return array + * @param int $assignmentId + * @return Session + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getUserSessionForAssignment(string $userId, int $assignmentId): Session { + $qb = $this->db->getQueryBuilder(); + $qb->select(Session::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('assignment_id', $qb->createPositionalParameter($assignmentId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))); + + return $this->findEntity($qb); + } + + /** + * @param string $userId + * @return list * @throws \OCP\DB\Exception */ public function getUserSessions(string $userId): array { diff --git a/lib/Listener/ChattyLLMTaskListener.php b/lib/Listener/ChattyLLMTaskListener.php index 46490878..5cdc3882 100644 --- a/lib/Listener/ChattyLLMTaskListener.php +++ b/lib/Listener/ChattyLLMTaskListener.php @@ -70,7 +70,7 @@ public function handle(Event $event): void { $message = new Message(); $message->setSessionId($sessionId); $message->setOcpTaskId($task->getId()); - $message->setRole('assistant'); + $message->setRole(Message::ROLE_ASSISTANT); $message->setTimestamp(time()); $sources = json_encode($taskOutput['sources'] ?? []); $message->setSources($sources ?: '[]'); diff --git a/lib/Migration/Version030500Date20260430083738.php b/lib/Migration/Version030500Date20260430083738.php new file mode 100644 index 00000000..80e1b650 --- /dev/null +++ b/lib/Migration/Version030500Date20260430083738.php @@ -0,0 +1,78 @@ +hasTable('assistant_assignments')) { + $schemaChanged = true; + $table = $schema->createTable('assistant_assignments'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 256, + ]); + $table->addColumn('prompt', Types::TEXT, [ + 'notnull' => true, + ]); + // this is an RFC 5545 RRULE + $table->addColumn('recurrence', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('starts_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('last_run_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('updated_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id'], 'assistant_assgnmts_user_id_idx'); + } + if ($schema->hasTable('assistant_chat_sns')) { + $schemaChanged = true; + $table = $schema->getTable('assistant_chat_sns'); + $table->addColumn('assignment_id', Types::BIGINT, [ + 'notnull' => false, + ]); + $table->addIndex(['user_id', 'assignment_id'], 'assistant_chat_assgnmt_uid'); + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index e7248ae0..c974d41b 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -84,6 +84,16 @@ * sessionAgencyPendingActions: ?array, * is_remembered: ?bool, * } + * + * @psalm-type AssistantAssignment = array{ + * id: int, + * user_id: string, + * prompt: string, + * recurrence: string, + * created_at: int, + * updated_at: int, + * starts_at: int + * } */ class ResponseDefinitions { } diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php new file mode 100644 index 00000000..4e0ba66f --- /dev/null +++ b/lib/Service/AssignmentsService.php @@ -0,0 +1,133 @@ +timeFactory->now()->getTimestamp(); + $assignment = new Assignment(); + $assignment->setUserId($userId); + $assignment->setPrompt($prompt); + $assignment->setStartsAt($startsAt); + $assignment->setLastRunAt(0); + $assignment->setCreatedAt($now); + $assignment->setUpdatedAt($now); + try { + $assignment->setRecurrence($recurrence); + } catch (\InvalidArgumentException $e) { + throw new BadRequestException('Invalid recurrence rule', previous: $e); + } + try { + $this->assignmentMapper->insert($assignment); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + $session = $this->chatService->createChatSession($userId, $this->timeFactory->now()->getTimestamp(), 'Assignment ' . $assignment->getId()); // TODO: Add a proper title here + $session->setAssignmentId($assignment->getId()); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$this->jobList->has(RunAssignmentsJob::class, ['userId' => $userId])) { + $this->jobList->add(RunAssignmentsJob::class, ['userId' => $userId]); + } + return $assignment; + } + + /** + * @throws InternalException + */ + public function runDueAssignmentsForUser(?string $userId) { + try { + foreach ($this->assignmentMapper->findDueAssignmentsForUser($userId) as $assignment) { + if ($assignment === null) { + continue; + } + $this->scheduleAssignmentRun($userId, $assignment->getId()); + } + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + } + + public function scheduleAssignmentRun(?string $userId, int $assignmentId): void { + try { + try { + $session = $this->sessionMapper->getUserSessionForAssignment($userId, $assignmentId); + } catch (DoesNotExistException $e) { + throw new NotFoundException(previous: $e); + } catch (MultipleObjectsReturnedException $e) { + throw new InternalException(previous: $e); + } + $assignment = $this->assignmentMapper->find($userId, $assignmentId); + $assignment->setLastRunAt($this->timeFactory->now()->getTimestamp()); + $this->assignmentMapper->update($assignment); + $this->chatService->createMessage($userId, $session->getId(), Message::ROLE_HUMAN, $assignment->getPrompt(), $this->timeFactory->now()->getTimestamp()); + $this->chatService->scheduleAssignmentMessageGeneration($userId, $session->getId()); + } catch (BadRequestException|InternalException|DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + $this->logger->error('Error while running assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + if (isset($session)) { + try { + $this->chatService->createMessage( + $userId, + $session->getId(), + message::ROLE_ASSISTANT, + $this->l10n->t('An error occurred while scheduling this assignment run. Reach out to your system administrator if this issue persists.'), + $this->timeFactory->now()->getTimestamp() + ); + } catch (BadRequestException|InternalException|NotFoundException|UnauthorizedException $e) { + $this->logger->error('Error while creating error message for assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + } + } + } catch (NotFoundException $e) { + try { + $this->assignmentMapper->delete($assignment); + } catch (Exception $e) { + $this->logger->error('Error while deleting assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + } + } catch (UnauthorizedException $e) { + // this should not happen + $this->logger->error('Unauthorized to run assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + } + } +} diff --git a/lib/Service/BadRequestException.php b/lib/Service/BadRequestException.php new file mode 100644 index 00000000..2012395b --- /dev/null +++ b/lib/Service/BadRequestException.php @@ -0,0 +1,11 @@ +l10n->t('Unauthorized')); + } + $user = $this->userManager->get($userId); + if ($user === null) { + throw new UnauthorizedException($this->l10n->t('User not found')); + } + + if ($timestamp > 10_000_000_000) { + $timestamp = intdiv($timestamp, 1000); + } + + $userInstructions = $this->appConfig->getValueString( + Application::APP_ID, + 'chat_user_instructions', + Application::CHAT_USER_INSTRUCTIONS, + lazy: true, + ) ?: Application::CHAT_USER_INSTRUCTIONS; + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + + $session = new Session(); + $session->setUserId($userId); + $session->setTitle($title); + $session->setTimestamp($timestamp); + $session->setAgencyConversationToken(null); + $session->setAgencyPendingActions(null); + try { + $this->sessionMapper->insert($session); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + + $systemMsg = new Message(); + $systemMsg->setSessionId($session->getId()); + $systemMsg->setRole('system'); + $systemMsg->setAttachments('[]'); + $systemMsg->setContent($userInstructions); + $systemMsg->setTimestamp($session->getTimestamp()); + $systemMsg->setSources('[]'); + try { + $this->messageMapper->insert($systemMsg); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + + return $session; + } + + /** + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function updateSession(?string $userId, int $sessionId, ?string $title = null, ?bool $isRemembered = null): Session { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $session = $this->sessionMapper->getUserSession($userId, $sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Session not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + if ($title === null && $isRemembered === null) { + return $session; + } + if ($title !== null) { + $session->setTitle($title); + } + if ($isRemembered !== null) { + $session->setIsRemembered($isRemembered); + // schedule summarizer jobs for this chat user + if ($isRemembered) { + $this->sessionSummaryService->scheduleJobsForUser($userId); + } + } + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + return $session; + } + + /** + * @throws InternalException + * @throws UnauthorizedException + */ + public function deleteSession(?string $userId, int $sessionId): void { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + + try { + $this->sessionMapper->deleteSession($userId, $sessionId); + $this->messageMapper->deleteMessagesBySession($sessionId); + } catch (Exception|\RuntimeException $e) { + throw new InternalException(previous: $e); + } + } + + /** + * @return list + * @throws InternalException + * @throws UnauthorizedException + */ + public function getSessionsForUser(?string $userId): array { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + return $this->sessionMapper->getUserSessions($userId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + } + + /** + * @throws BadRequestException + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function createMessage(?string $userId, int $sessionId, string $role, string $content, int $timestamp, ?array $attachments = null, bool $firstHumanMessage = false): Message { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + + if (strlen($content) > Application::MAX_TEXT_INPUT_LENGTH) { + throw new BadRequestException($this->l10n->t('The new message is too long')); + } + + if ($timestamp > 10_000_000_000) { + $timestamp = intdiv($timestamp, 1000); + } + + // refuse empty text content if context agent is not available (we do classic chat) AND there is no attachment + // in other words: accept empty content if we are using agency OR there are attachments + $content = trim($content); + if (empty($content) + && !$this->isContextAgentAvailable() + && $attachments === null + ) { + throw new BadRequestException($this->l10n->t('Message content is empty')); + } + + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + $message = new Message(); + $message->setSessionId($sessionId); + $message->setRole($role); + $message->setContent($content); + $message->setTimestamp($timestamp); + $message->setSources('[]'); + $message->setAttachments('[]'); + if ($attachments !== null) { + try { + $encodedAttachments = json_encode($attachments, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new BadRequestException($this->l10n->t('Failed to encode attachments')); + } + if ($encodedAttachments !== false) { + $message->setAttachments($encodedAttachments); + } + } + try { + $this->messageMapper->insert($message); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if ($firstHumanMessage) { + // set the title of the session based on first human message + try { + $this->sessionMapper->updateSessionTitle( + $userId, + $sessionId, + strlen($content) > 140 ? mb_substr($content, 0, 140) . '...' : $content, + ); + } catch (Exception $e) { + $this->logger->error('Failed to update session title', ['exception' => $e]); + // pass as the main operation succeeded + } + } + return $message; + } + + /** + * @return list + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function getSessionMessages(?string $userId, int $sessionId, $limit = 20, int $cursor = 0): array { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + /** @var list $messages */ + try { + $messages = $this->messageMapper->getMessages($sessionId, $cursor, $limit); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if ($messages[0]->getRole() === 'system') { + array_shift($messages); + } + + return $messages; + } + + /** + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function getSessionMessage(?string $userId, int $sessionId, int $messageId): Message { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + try { + return $this->messageMapper->getMessageById($sessionId, $messageId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Message not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + } + + /** + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function deleteSessionMessage(?string $userId, int $sessionId, int $messageId): void { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + try { + $this->messageMapper->deleteMessageById($sessionId, $messageId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + } + + /** + * @throws InternalException + * @throws BadRequestException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function scheduleMessageGeneration(?string $userId, int $sessionId, int $agencyConfirm = 0): int { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + if ($this->isContextAgentAvailable()) { + try { + $lastUserMessage = $this->messageMapper->getLastHumanMessage($sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('No user message found in this session'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + + + try { + $session = $this->sessionMapper->getUserSession($userId, $sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Session not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + $lastConversationToken = $session->getAgencyConversationToken() ?? '{}'; + + $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; + $audioAttachment = $lastAttachments[0] ?? null; + $isContextAgentAudioAvailable = $this->isContextAgentAudioAvailable(); + if ($audioAttachment !== null + && isset($audioAttachment['type']) + && $audioAttachment['type'] === 'Audio' + && $isContextAgentAudioAvailable + ) { + // audio agency + $fileId = $audioAttachment['file_id']; + $taskId = $this->scheduleAgencyAudioTask($userId, $fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId()); + } else { + // classic agency + $prompt = $lastUserMessage->getContent(); + $taskId = $this->scheduleAgencyTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId); + } + } else { + // classic chat + $systemPrompt = ''; + try { + $firstMessage = $this->messageMapper->getFirstNMessages($sessionId, 1); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('No message found in this session'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + if ($firstMessage->getRole() === 'system') { + $systemPrompt = $firstMessage->getContent(); + } + try { + $history = $this->getRawLastMessages($sessionId); + } catch (Exception|AppConfigTypeConflictException $e) { + throw new InternalException(previous: $e); + } + do { + $lastUserMessage = array_pop($history); + } while ($lastUserMessage->getRole() !== Message::ROLE_HUMAN); + + $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; + $audioAttachment = $lastAttachments[0] ?? null; + $isAudioToAudioAvailable = $this->isContextAgentAudioAvailable(); + if ($audioAttachment !== null + && isset($audioAttachment['type']) + && $audioAttachment['type'] === 'Audio' + && $isAudioToAudioAvailable + ) { + // for an audio chat task, let's try to get the remote audio IDs for all the previous audio messages + $history = $this->getAudioHistory($history); + $fileId = $audioAttachment['file_id']; + $taskId = $this->scheduleAudioChatTask($userId, $fileId, $systemPrompt, $history, $sessionId, $lastUserMessage->getId()); + } else { + // for a text chat task, let's only use text in the history + $history = array_map(static function (Message $message) { + return json_encode([ + 'role' => $message->getRole(), + 'content' => $message->getContent(), + ]); + }, $history); + $taskId = $this->scheduleLLMChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $history, $sessionId); + } + } + return $taskId; + } + + /** + * @throws InternalException + * @throws BadRequestException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function scheduleAssignmentMessageGeneration(?string $userId, int $sessionId): int { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + if (!$this->isContextAgentAvailable()) { + throw new BadRequestException('context_agent_not_available'); + } + try { + $lastUserMessage = $this->messageMapper->getLastHumanMessage($sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('No user message found in this session'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + + + try { + $session = $this->sessionMapper->getUserSession($userId, $sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Session not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + // We reset the context for each interaction, because this is an assignment, + // the assistant does not remember things between assignment runs + $lastConversationToken = '{}'; + + // classic agency + $prompt = $lastUserMessage->getContent(); + $taskId = $this->scheduleAgencyTask($userId, $prompt, 0, $lastConversationToken, $sessionId); + return $taskId; + } + + /** + * @throws BadRequestException + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException|\JsonException + */ + public function scheduleTitleGeneration(?string $userId, int $sessionId): int { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + $user = $this->userManager->get($userId); + if ($user === null) { + throw new InternalException($this->l10n->t('User not found')); + } + + $userInstructions = $this->appConfig->getValueString( + Application::APP_ID, + 'chat_user_instructions_title', + Application::CHAT_USER_INSTRUCTIONS_TITLE, + lazy: true, + ) ?: Application::CHAT_USER_INSTRUCTIONS_TITLE; + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + + try { + $history = $this->getRawLastMessages($sessionId); + } catch (Exception|AppConfigTypeConflictException $e) { + throw new InternalException(previous: $e); + } + // history is a list of JSON strings + $history = array_map(static function (Message $message) { + return json_encode([ + 'role' => $message->getRole(), + 'content' => $message->getContent(), + ], JSON_THROW_ON_ERROR); + }, $history); + return $this->scheduleLLMChatTask($userId, $userInstructions, $userInstructions, $history, $sessionId, false); + } + + public function isContextAgentAvailable(): bool { + if (!class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentInteraction')) { + return false; + } + return in_array(\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID, $this->taskProcessingManager->getAvailableTaskTypeIds()); + } + + public function isContextAgentAudioAvailable(): bool { + if (!class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentAudioInteraction')) { + return false; + } + return in_array(\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID, $this->taskProcessingManager->getAvailableTaskTypeIds()); + } + + + private function getAudioHistory(array $history): array { + // history is a list of JSON strings + // The content is the remote audio ID (or the transcription as fallback) + // We only use the audio ID for assistant messages, if we have one and if it's not expired + // The audio ID is found in integration_openai's AudioToAudioChat response for example + // It is an optional output of AudioToAudioChat tasks + return array_map(static function (Message $message) { + $entry = [ + 'role' => $message->getRole(), + ]; + $attachments = $message->jsonSerialize()['attachments']; + if ($message->getRole() === Message::ROLE_ASSISTANT + && count($attachments) > 0 + && $attachments[0]['type'] === 'Audio' + && isset($attachments[0]['remote_audio_id']) + ) { + if (!isset($attachments[0]['remote_audio_expires_at']) + || time() < $attachments[0]['remote_audio_expires_at'] + ) { + $entry['audio'] = ['id' => $attachments[0]['remote_audio_id']]; + return json_encode($entry); + } + } + + $entry['content'] = $message->getContent(); + return json_encode($entry); + }, $history); + } + + /** + * Get the last N messages (assistant and user messages, avoid initial system prompt) as an array + * + * @param integer $sessionId + * @return array + * @throws AppConfigTypeConflictException + * @throws \OCP\DB\Exception + */ + private function getRawLastMessages(int $sessionId): array { + $lastNMessages = (int)$this->appConfig->getValueString(Application::APP_ID, 'chat_last_n_messages', '10', lazy: true); + $messages = $this->messageMapper->getMessages($sessionId, 0, $lastNMessages); + + if ($messages[0]->getRole() === 'system') { + array_shift($messages); + } + return $messages; + } + + /** + * @param string|null $userId + * @param string $customId + * @return void + * @throws BadRequestException + * @throws InternalException + */ + private function checkIfSessionIsThinking(?string $userId, string $customId): void { + try { + $tasks = $this->taskProcessingManager->getUserTasksByApp($userId, Application::APP_ID . ':chatty-llm', $customId); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + throw new BadRequestException('task_query_failed', previous: $e); + } catch (\JsonException $e) { + throw new InternalException(previous: $e); + } + $tasks = array_filter($tasks, static function (Task $task) { + return $task->getStatus() === Task::STATUS_RUNNING || $task->getStatus() === Task::STATUS_SCHEDULED; + }); + // prevent scheduling multiple llm tasks simultaneously for one session + if (!empty($tasks)) { + throw new BadRequestException('session_already_thinking'); + } + } + + /** + * Schedule a Chat task + * + * @throws BadRequestException + * @throws InternalException + */ + private function scheduleLLMChatTask( + ?string $userId, + string $newPrompt, + string $systemPrompt, + array $history, + int $sessionId, + bool $isMessage = true, + ): int { + $customId = ($isMessage + ? 'chatty-llm:' + : 'chatty-title:') . $sessionId; + $this->checkIfSessionIsThinking($userId, $customId); + $input = [ + 'input' => $newPrompt, + 'system_prompt' => $systemPrompt, + 'history' => $history, + ]; + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextChat::ID]['optionalInputShape']['memories'])) { + $input['memories'] = $this->sessionSummaryService->getMemories($userId); + } + $task = new Task(TextToTextChat::ID, $input, Application::APP_ID . ':chatty-llm', $userId, $customId); + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (PreConditionNotMetException $e) { + throw new BadRequestException('pre_condition_not_met', previous: $e); + } catch (\OCP\TaskProcessing\Exception\UnauthorizedException $e) { + throw new BadRequestException('unauthorized', previous: $e); + } catch (ValidationException $e) { + throw new BadRequestException('validation_failed', previous: $e); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalException(previous: $e); + } + return $task->getId() ?? 0; + } + + /** + * Schedule an agency chat task + * + * @throws BadRequestException + * @throws InternalException + */ + private function scheduleAgencyTask( + ?string $userId, + string $content, + int $confirmation, + string $conversationToken, + int $sessionId, + ): int { + $customId = 'chatty-llm:' . $sessionId; + $this->checkIfSessionIsThinking($userId, $customId); + $taskInput = [ + 'input' => $content, + 'confirmation' => $confirmation, + 'conversation_token' => $conversationToken, + ]; + /** @psalm-suppress UndefinedClass */ + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]['optionalInputShape']['memories'])) { + $taskInput['memories'] = $this->sessionSummaryService->getMemories($userId); + } + /** @psalm-suppress UndefinedClass */ + $task = new Task( + \OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID, + $taskInput, + Application::APP_ID . ':chatty-llm', + $userId, + $customId + ); + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (PreConditionNotMetException $e) { + throw new BadRequestException('pre_condition_not_met', previous: $e); + } catch (\OCP\TaskProcessing\Exception\UnauthorizedException $e) { + throw new BadRequestException('unauthorized', previous: $e); + } catch (ValidationException $e) { + throw new BadRequestException('validation_failed', previous: $e); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalException(previous: $e); + } + return $task->getId() ?? 0; + } + + /** + * Schedule an audio chat task + * @throws BadRequestException + * @throws InternalException + */ + private function scheduleAudioChatTask( + ?string $userId, + int $audioFileId, + string $systemPrompt, + array $history, + int $sessionId, + int $queryMessageId, + ): int { + $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; + $this->checkIfSessionIsThinking($userId, $customId); + $input = [ + 'input' => $audioFileId, + 'system_prompt' => $systemPrompt, + 'history' => $history, + ]; + /** @psalm-suppress UndefinedClass */ + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]['optionalInputShape']['memories'])) { + $input['memories'] = $this->sessionSummaryService->getMemories($userId); + } + /** @psalm-suppress UndefinedClass */ + $task = new Task( + \OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID, + $input, + Application::APP_ID . ':chatty-llm', + $userId, + $customId, + ); + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (PreConditionNotMetException $e) { + throw new BadRequestException('pre_condition_not_met', previous: $e); + } catch (\OCP\TaskProcessing\Exception\UnauthorizedException $e) { + throw new BadRequestException('unauthorized', previous: $e); + } catch (ValidationException $e) { + throw new BadRequestException('validation_failed', previous: $e); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalException(previous: $e); + } + return $task->getId() ?? 0; + } + + /** + * Schedule an agency audio chat task + * @throws BadRequestException + * @throws InternalException + */ + private function scheduleAgencyAudioTask( + ?string $userId, + int $audioFileId, + int $confirmation, + string $conversationToken, + int $sessionId, + int $queryMessageId, + ): int { + $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; + $this->checkIfSessionIsThinking($userId, $customId); + $taskInput = [ + 'input' => $audioFileId, + 'confirmation' => $confirmation, + 'conversation_token' => $conversationToken, + ]; + /** @psalm-suppress UndefinedClass */ + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]['optionalInputShape']['memories'])) { + $taskInput['memories'] = $this->sessionSummaryService->getMemories($userId); + } + /** @psalm-suppress UndefinedClass */ + $task = new Task( + \OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID, + $taskInput, + Application::APP_ID . ':chatty-llm', + $userId, + $customId + ); + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (PreConditionNotMetException $e) { + throw new BadRequestException('pre_condition_not_met', previous: $e); + } catch (\OCP\TaskProcessing\Exception\UnauthorizedException $e) { + throw new BadRequestException('unauthorized', previous: $e); + } catch (ValidationException $e) { + throw new BadRequestException('validation_failed', previous: $e); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalException(previous: $e); + } + return $task->getId() ?? 0; + } +} diff --git a/lib/Service/InternalException.php b/lib/Service/InternalException.php new file mode 100644 index 00000000..334a540a --- /dev/null +++ b/lib/Service/InternalException.php @@ -0,0 +1,11 @@ +}, array{}>]]> + + + + + +