From 062fe7f5a8820dce06686fda1c0bbed5b35bed36 Mon Sep 17 00:00:00 2001 From: Jana Peper Date: Thu, 21 May 2026 09:44:52 +0200 Subject: [PATCH 01/59] Revert "Merge pull request #539 from nextcloud/feat/assignment-notify" This reverts commit 1ff0a937043fb7b4e36106463b8719c87bee6497, reversing changes made to 7a3bdd9c0b8ac6758907121a7edc6afdef248c2c. --- lib/AppInfo/Application.php | 1 - lib/Listener/ChattyLLMTaskListener.php | 27 +--------- lib/Notification/Notifier.php | 75 -------------------------- lib/Service/NotificationService.php | 31 ----------- 4 files changed, 1 insertion(+), 133 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3bb0d9579..74297db39 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -92,7 +92,6 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(TaskSuccessfulEvent::class, TaskSuccessfulListener::class); $context->registerEventListener(TaskFailedEvent::class, TaskFailedListener::class); $context->registerEventListener(TaskSuccessfulEvent::class, ChattyLLMTaskListener::class); - $context->registerEventListener(TaskFailedEvent::class, ChattyLLMTaskListener::class); $context->registerEventListener(TaskSuccessfulEvent::class, FileActionTaskSuccessfulListener::class); $context->registerEventListener(TaskSuccessfulEvent::class, NewFileMenuTaskSuccessfulListener::class); $context->registerEventListener(TaskFailedEvent::class, FileActionTaskFailedListener::class); diff --git a/lib/Listener/ChattyLLMTaskListener.php b/lib/Listener/ChattyLLMTaskListener.php index cbb7e4cb6..5cdc38828 100644 --- a/lib/Listener/ChattyLLMTaskListener.php +++ b/lib/Listener/ChattyLLMTaskListener.php @@ -13,17 +13,15 @@ use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\MessageMapper; use OCA\Assistant\Db\ChattyLLM\SessionMapper; -use OCA\Assistant\Service\NotificationService; use OCA\Assistant\Service\TaskProcessingService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; -use OCP\TaskProcessing\Events\TaskFailedEvent; use OCP\TaskProcessing\Events\TaskSuccessfulEvent; use OCP\TaskProcessing\Task; use Psr\Log\LoggerInterface; /** - * @template-implements IEventListener + * @template-implements IEventListener */ class ChattyLLMTaskListener implements IEventListener { @@ -32,26 +30,10 @@ public function __construct( private SessionMapper $sessionMapper, private TaskProcessingService $taskProcessingService, private LoggerInterface $logger, - private NotificationService $notificationService, ) { } public function handle(Event $event): void { - if ($event instanceof TaskFailedEvent) { - $task = $event->getTask(); - $customId = $task->getCustomId(); - if (preg_match('/^chatty-llm:(\d+)/', $customId, $matches)) { - $sessionId = (int)$matches[1]; - $session = $this->sessionMapper->getUserSession($task->getUserId(), $sessionId); - $assignmentId = $session->getAssignmentId(); - if ($assignmentId !== null) { - $this->notificationService->sendAssignmentNotification( - $task->getUserId(), $task, $session - ); - } - } - return; - } if (!($event instanceof TaskSuccessfulEvent)) { return; } @@ -145,13 +127,6 @@ public function handle(Event $event): void { // Set flag that the conversation summary needs to be regenerated $session->setIsSummaryUpToDate(false); - $assignmentId = $session->getAssignmentId(); - if ($assignmentId !== null) { - $this->notificationService->sendAssignmentNotification( - $task->getUserId(), $task, $session - ); - } - $this->sessionMapper->update($session); } } diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 299288e78..9916fb6a0 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -338,81 +338,6 @@ public function prepare(INotification $notification, string $languageCode): INot return $notification; - case 'assignment_approval_pending': - $subject = $l->t('Assignment run pending review'); - - $iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')); - - $message = $l->t('"%s" awaits your review before continuing.', [$params['sessionTitle']]); - - $notification - ->setParsedSubject($subject) - ->setParsedMessage($message) - // TODO: link directly to assignment - ->setLink($this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantStandalonePage', ['sessionId' => $params['sessionId']])) - ->setIcon($iconUrl); - - $actionLabel = $l->t('View assignment'); - $action = $notification->createAction(); - $action->setLabel($actionLabel) - ->setParsedLabel($actionLabel) - ->setLink($notification->getLink(), IAction::TYPE_WEB) - ->setPrimary(true); - - $notification->addParsedAction($action); - - return $notification; - - case 'assignment_successful': - $subject = $l->t('Assignment succeeded'); - - $iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')); - - $message = $l->t('"%s" was run successfully.', [$params['sessionTitle']]); - - $notification - ->setParsedSubject($subject) - ->setParsedMessage($message) - // TODO: link directly to assignment - ->setLink($this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantStandalonePage', ['sessionId' => $params['sessionId']])) - ->setIcon($iconUrl); - - $actionLabel = $l->t('View assignment result'); - $action = $notification->createAction(); - $action->setLabel($actionLabel) - ->setParsedLabel($actionLabel) - ->setLink($notification->getLink(), IAction::TYPE_WEB) - ->setPrimary(true); - - $notification->addParsedAction($action); - - return $notification; - - case 'assignment_failure': - $subject = $l->t('Assignment failed'); - - $iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')); - - $message = $l->t('"%s" failed to run.', [$params['sessionTitle']]); - - $notification - ->setParsedSubject($subject) - ->setParsedMessage($message) - // TODO: link directly to assignment - ->setLink($this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantStandalonePage', ['sessionId' => $params['sessionId']])) - ->setIcon($iconUrl); - - $actionLabel = $l->t('View assignment result'); - $action = $notification->createAction(); - $action->setLabel($actionLabel) - ->setParsedLabel($actionLabel) - ->setLink($notification->getLink(), IAction::TYPE_WEB) - ->setPrimary(true); - - $notification->addParsedAction($action); - - return $notification; - default: // Unknown subject => Unknown notification => throw throw new UnknownActivityException(); diff --git a/lib/Service/NotificationService.php b/lib/Service/NotificationService.php index 5fc3ab147..82a596642 100644 --- a/lib/Service/NotificationService.php +++ b/lib/Service/NotificationService.php @@ -9,7 +9,6 @@ use DateTime; use OCA\Assistant\AppInfo\Application; -use OCA\Assistant\Db\ChattyLLM\Session; use OCP\IURLGenerator; use OCP\Notification\IManager as INotificationManager; use OCP\TaskProcessing\Task; @@ -134,34 +133,4 @@ public function sendNewImageFileNotification( $manager->notify($notification); } - - public function sendAssignmentNotification(?string $userId, Task $task, Session $session): void { - if ($userId === null) { - return; - } - $manager = $this->notificationManager; - $notification = $manager->createNotification(); - - $taskSuccessful = $task->getStatus() === Task::STATUS_SUCCESSFUL; - $pendingActions = $session->getAgencyPendingActions(); - - $params = [ - 'sessionId' => (string)$session->getId(), - 'sessionTitle' => $session->getTitle(), - ]; - - $subject = $taskSuccessful - ? ($pendingActions === null - ? 'assignment_successful' - : 'assignment_approval_pending') - : 'assignment_failure'; - - $notification->setApp(Application::APP_ID) - ->setUser($userId) - ->setDateTime(new DateTime()) - ->setSubject($subject, $params) - ->setObject('session', (string)$session->getId()); - - $manager->notify($notification); - } } From edba3267da055e9d883134a2853c45dde4a723ea Mon Sep 17 00:00:00 2001 From: Jana Peper Date: Thu, 21 May 2026 09:45:00 +0200 Subject: [PATCH 02/59] Revert "Merge pull request #522 from nextcloud/feat/assignments" This reverts commit 815932453251a72667b139c81c98692e2ff0f3e9, reversing changes made to 33548465dc0a4d1c7d78d733fc5583e1d2482c56. --- appinfo/info.xml | 2 +- composer.json | 1 - composer.lock | 275 +--- lib/BackgroundJob/RunAssignmentsJob.php | 38 - lib/Controller/AssignmentsApiController.php | 214 ---- lib/Controller/ChattyLLMController.php | 599 +++++++-- lib/Db/Assignment.php | 136 -- lib/Db/AssignmentMapper.php | 89 -- lib/Db/ChattyLLM/Message.php | 5 - lib/Db/ChattyLLM/MessageMapper.php | 6 +- lib/Db/ChattyLLM/Session.php | 12 - lib/Db/ChattyLLM/SessionMapper.php | 20 +- lib/Listener/ChattyLLMTaskListener.php | 2 +- .../Version030500Date20260430083738.php | 85 -- lib/ResponseDefinitions.php | 12 - lib/Service/AssignmentsService.php | 142 --- lib/Service/BadRequestException.php | 11 - lib/Service/ChatService.php | 806 ------------ lib/Service/InternalException.php | 11 - lib/Service/NotFoundException.php | 11 - lib/Service/SessionSummaryService.php | 3 +- lib/Service/UnauthorizedException.php | 10 - openapi.json | 1101 +---------------- tests/psalm-baseline.xml | 6 - 24 files changed, 536 insertions(+), 3061 deletions(-) delete mode 100644 lib/BackgroundJob/RunAssignmentsJob.php delete mode 100644 lib/Controller/AssignmentsApiController.php delete mode 100644 lib/Db/Assignment.php delete mode 100644 lib/Db/AssignmentMapper.php delete mode 100644 lib/Migration/Version030500Date20260430083738.php delete mode 100644 lib/Service/AssignmentsService.php delete mode 100644 lib/Service/BadRequestException.php delete mode 100644 lib/Service/ChatService.php delete mode 100644 lib/Service/InternalException.php delete mode 100644 lib/Service/NotFoundException.php delete mode 100644 lib/Service/UnauthorizedException.php diff --git a/appinfo/info.xml b/appinfo/info.xml index b1ad44415..61eee98fe 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.5.0-dev.1 + 3.4.1 agpl Julien Veyssier Assistant diff --git a/composer.json b/composer.json index 6c9d816e1..0b4f1448e 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "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 2cfb9b185..0c7e6a5f6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,142 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8b9a5743024889b768ae6fc5144429d8", + "content-hash": "1044674e3dc6cc194dc15097e173000b", "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", @@ -485,65 +351,6 @@ }, "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", @@ -679,86 +486,6 @@ } ], "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 deleted file mode 100644 index 8e3106ccc..000000000 --- a/lib/BackgroundJob/RunAssignmentsJob.php +++ /dev/null @@ -1,38 +0,0 @@ -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|UnauthorizedException $e) { - $this->logger->error('Error running assignments for user ' . $userId, ['exception' => $e]); - } - } -} diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php deleted file mode 100644 index 631dd2577..000000000 --- a/lib/Controller/AssignmentsApiController.php +++ /dev/null @@ -1,214 +0,0 @@ -|DataResponse - * - * 200: User assignments returned - * 403: User not logged in - * 400: Validation error - */ - #[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 creating assignment for user ' . $this->userId, ['exception' => $e]); - return new DataResponse('', Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (UnauthorizedException $e) { - return new DataResponse('', Http::STATUS_FORBIDDEN); - } catch (BadRequestException $e) { - return new DataResponse('', Http::STATUS_BAD_REQUEST); - } - } - - /** - * Get user's assignments - * - * Get a list of assignments 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('', Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - 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_INTERNAL_SERVER_ERROR); - } 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_INTERNAL_SERVER_ERROR); - } 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_INTERNAL_SERVER_ERROR); - } 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 8a6aac367..8758e4dce 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -10,12 +10,10 @@ 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\BadRequestException; -use OCA\Assistant\Service\ChatService; -use OCA\Assistant\Service\InternalException; -use OCA\Assistant\Service\UnauthorizedException; +use OCA\Assistant\Service\SessionSummaryService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Http; @@ -28,9 +26,14 @@ 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; /** @@ -53,7 +56,7 @@ public function __construct( private IAppConfig $appConfig, private IUserManager $userManager, private ?string $userId, - private ChatService $chatService, + private SessionSummaryService $sessionSummaryService, ) { parent::__construct($appName, $request); $this->agencyActionData = [ @@ -210,16 +213,50 @@ 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 = $this->chatService->createChatSession($this->userId, $timestamp, $title); + $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); + return new JSONResponse([ 'session' => $session->jsonSerialize(), ]); - } catch (InternalException $e) { + } catch (\OCP\DB\Exception|\RuntimeException $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); } } @@ -230,25 +267,24 @@ 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->chatService->updateSession($this->userId, $sessionId, $title); + $this->sessionMapper->updateSessionTitle($this->userId, $sessionId, $title); return new JSONResponse(); - } catch (InternalException $e) { + } catch (\OCP\DB\Exception|\RuntimeException $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); } } @@ -258,25 +294,40 @@ public function updateSessionTitle(int $sessionId, string $title): JSONResponse * @param integer $sessionId The chat session ID * @param string|null $title The new chat session title * @param bool|null $is_remembered The new is_remembered status: Whether to remember the insights from this chat session across all chat session - * @return JSONResponse|JSONResponse + * @return JSONResponse|JSONResponse * * 200: The title has been updated successfully * 404: The session was not found - * 401: User is not logged in */ #[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 { - $this->chatService->updateSession($this->userId, $sessionId, $title, $is_remembered); + $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); return new JSONResponse(); - } catch (InternalException $e) { + } catch (\OCP\DB\Exception|\RuntimeException $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) { + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND); - } catch (UnauthorizedException $e) { - return new JSONResponse(['error' => $this->l10n->t('User is not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -294,15 +345,18 @@ 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->chatService->deleteSession($this->userId, $sessionId); + $this->sessionMapper->deleteSession($this->userId, $sessionId); + $this->messageMapper->deleteMessagesBySession($sessionId); return new JSONResponse(); - } catch (InternalException $e) { + } catch (\OCP\DB\Exception|\RuntimeException $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); } } @@ -319,18 +373,16 @@ 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->chatService->getSessionsForUser($this->userId); - /** @var list $serializedSessions */ - $serializedSessions = array_map(static function ($session) { - return $session->jsonSerialize(); - }, $sessions); - return new JSONResponse($serializedSessions); - } catch (InternalException $e) { + $sessions = $this->sessionMapper->getUserSessions($this->userId); + return new JSONResponse($sessions); + } catch (\OCP\DB\Exception $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); } } @@ -357,18 +409,61 @@ 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 { - $message = $this->chatService->createMessage($this->userId, $sessionId, $role, $content, $timestamp, $attachments, $firstHumanMessage); + $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, + ); + } + return new JSONResponse($message->jsonSerialize()); - } catch (InternalException $e) { + } catch (\OCP\DB\Exception $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); } } @@ -389,18 +484,28 @@ 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 { - $messages = $this->chatService->getSessionMessages($this->userId, $sessionId, $limit, $cursor); + $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); + } + return new JSONResponse(array_map(static function (Message $message) { return $message->jsonSerialize(); }, $messages)); - } catch (InternalException $e) { + } catch (\OCP\DB\Exception $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); } } @@ -420,16 +525,22 @@ 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 { - $message = $this->chatService->getSessionMessage($this->userId, $sessionId, $messageId); + $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); + return new JSONResponse($message->jsonSerialize()); - } catch (InternalException $e) { + } catch (\OCP\DB\Exception $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); } } @@ -454,15 +565,19 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { } try { - $this->chatService->deleteSessionMessage($this->userId, $sessionId, $messageId); + $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); + return new JSONResponse(); - } catch (InternalException $e) { + } catch (\OCP\DB\Exception|\RuntimeException $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); } } @@ -487,22 +602,129 @@ 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 { - 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) { + 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); + } + + 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 * @@ -525,20 +747,26 @@ public function generateForSession(int $sessionId, int $agencyConfirm = 0): JSON #[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->chatService->deleteSessionMessage($this->userId, $sessionId, $messageId); - $taskId = $this->chatService->scheduleMessageGeneration($this->userId, $sessionId, 0); - return new JSONResponse(['taskId' => $taskId]); - } catch (InternalException $e) { + $this->messageMapper->deleteMessageById($sessionId, $messageId); + } catch (\OCP\DB\Exception|\RuntimeException $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); } /** @@ -680,6 +908,10 @@ 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 @@ -689,18 +921,47 @@ 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 { - $taskId = $this->chatService->scheduleTitleGeneration($this->userId, $sessionId); + $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); + } return new JSONResponse(['taskId' => $taskId]); - } catch (InternalException|\JsonException $e) { + } catch (\OCP\DB\Exception|\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); - } 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); } } @@ -774,4 +1035,160 @@ 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 deleted file mode 100644 index ee4e03bc0..000000000 --- a/lib/Db/Assignment.php +++ /dev/null @@ -1,136 +0,0 @@ -addType('userId', Types::STRING); - $this->addType('prompt', Types::STRING); - $this->addType('recurrence', Types::STRING); - $this->addType('startsAt', Types::BIGINT); - $this->addType('createdAt', Types::BIGINT); - $this->addType('updatedAt', Types::BIGINT); - $this->addType('lastRunAt', Types::BIGINT); - } - - #[\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 deleted file mode 100644 index f47462542..000000000 --- a/lib/Db/AssignmentMapper.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -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 c94611e8c..b60252bb3 100644 --- a/lib/Db/ChattyLLM/Message.php +++ b/lib/Db/ChattyLLM/Message.php @@ -29,11 +29,6 @@ * @method \void setAttachments(?string $attachments) */ class Message extends Entity implements \JsonSerializable { - public const ROLE_HUMAN = 'human'; - - public const ROLE_ASSISTANT = 'assistant'; - public const ROLE_SYSTEM = 'system'; - /** @var int */ protected $sessionId; /** @var string */ diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index a0c4c0ce8..44ff61922 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(Message::ROLE_HUMAN, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter('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(Message::ROLE_HUMAN, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter('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 list + * @return array * @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 0c5fc6d08..fad238403 100644 --- a/lib/Db/ChattyLLM/Session.php +++ b/lib/Db/ChattyLLM/Session.php @@ -25,8 +25,6 @@ * @method \void setAgencyConversationToken(?string $agencyConversationToken) * @method \string|null getAgencyPendingActions() * @method \void setAgencyPendingActions(?string $agencyPendingActions) - * @method \void setAssignmentId(?int $id) - * @method \int|null getAssignmentId() */ class Session extends Entity implements \JsonSerializable { /** @var string */ @@ -57,12 +55,6 @@ 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', @@ -74,7 +66,6 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'is_summary_up_to_date', 'is_remembered', - 'assignment_id' ]; public static $fields = [ 'id', @@ -86,7 +77,6 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'isSummaryUpToDate', 'isRemembered', - 'assignmentId' ]; public function __construct() { @@ -98,7 +88,6 @@ public function __construct() { $this->addType('summary', Types::TEXT); $this->addType('isSummaryUpToDate', Types::SMALLINT); $this->addType('isRemembered', Types::SMALLINT); - $this->addType('assignmentId', Types::BIGINT); } #[\ReturnTypeWillChange] @@ -113,7 +102,6 @@ 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 babf812c1..9006bcf63 100644 --- a/lib/Db/ChattyLLM/SessionMapper.php +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -66,25 +66,7 @@ public function getUserSession(string $userId, int $sessionId): Session { /** * @param string $userId - * @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 + * @return array * @throws \OCP\DB\Exception */ public function getUserSessions(string $userId): array { diff --git a/lib/Listener/ChattyLLMTaskListener.php b/lib/Listener/ChattyLLMTaskListener.php index 5cdc38828..464908788 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(Message::ROLE_ASSISTANT); + $message->setRole('assistant'); $message->setTimestamp(time()); $sources = json_encode($taskOutput['sources'] ?? []); $message->setSources($sources ?: '[]'); diff --git a/lib/Migration/Version030500Date20260430083738.php b/lib/Migration/Version030500Date20260430083738.php deleted file mode 100644 index fdca676fc..000000000 --- a/lib/Migration/Version030500Date20260430083738.php +++ /dev/null @@ -1,85 +0,0 @@ -hasTable('assistant_assignments')) { - $schemaChanged = true; - $table = $schema->createTable('assistant_assignments'); - $table->addColumn('id', Types::BIGINT, [ - 'autoincrement' => true, - 'unsigned' => 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')) { - $table = $schema->getTable('assistant_chat_sns'); - if (!$table->hasColumn('assignment_id')) { - $schemaChanged = true; - $table->addColumn('assignment_id', Types::BIGINT, [ - 'notnull' => false, - 'unsigned' => true, - ]); - } - if (!$table->hasIndex('assistant_chat_assgnmt_uid')) { - $schemaChanged = true; - $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 90015c5b9..e7248ae04 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -60,7 +60,6 @@ * summary: ?string, * is_remembered: bool, * is_summary_up_to_date: bool, - * assignment_id: ?int * } * * @psalm-type AssistantChatMessage = array{ @@ -85,17 +84,6 @@ * 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, - * last_run_at: int - * } */ class ResponseDefinitions { } diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php deleted file mode 100644 index 99de53668..000000000 --- a/lib/Service/AssignmentsService.php +++ /dev/null @@ -1,142 +0,0 @@ -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|UnauthorizedException - */ - public function runDueAssignmentsForUser(?string $userId): void { - if ($userId === null) { - throw new UnauthorizedException(); - } - 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); - } - } - - /** - * @throws UnauthorizedException - */ - public function scheduleAssignmentRun(?string $userId, int $assignmentId): void { - if ($userId === null) { - throw new UnauthorizedException(); - } - 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 ' . $assignmentId . ' 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 ' . $assignmentId . ' for user ' . $userId, ['exception' => $e]); - } - } - } catch (NotFoundException $e) { - try { - $assignment = $this->assignmentMapper->find($userId, $assignmentId); - $this->assignmentMapper->delete($assignment); - } catch (Exception|MultipleObjectsReturnedException $e) { - $this->logger->error('Error while deleting assignment ' . $assignmentId . ' for user ' . $userId, ['exception' => $e]); - } catch (DoesNotExistException $e) { - // pass - } - } - } -} diff --git a/lib/Service/BadRequestException.php b/lib/Service/BadRequestException.php deleted file mode 100644 index 2012395b9..000000000 --- a/lib/Service/BadRequestException.php +++ /dev/null @@ -1,11 +0,0 @@ -l10n->t('Unauthorized')); - } - $user = $this->userManager->get($userId); - if ($user === null) { - throw new UnauthorizedException($this->l10n->t('User not found')); - } - - $timestamp ??= $this->timeFactory->now()->getTimestamp(); - - 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(Message::ROLE_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, int $limit = 20, int $cursor = 0): array { - if ($userId === null) { - throw new UnauthorizedException($this->l10n->t('Unauthorized')); - } - - $Limit = max(1, $limit); - - 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 (!empty($messages) && $messages[0]->getRole() === Message::ROLE_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() ?? self::EMPTY_CONVERSATION_TOKEN; - - $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() === Message::ROLE_SYSTEM) { - $systemPrompt = $firstMessage->getContent(); - } - try { - $history = $this->getRawLastMessages($sessionId); - } catch (Exception|AppConfigTypeConflictException $e) { - throw new InternalException(previous: $e); - } - $lastUserMessage = null; - while ($history !== []) { - $lastUserMessage = array_pop($history); - if ($lastUserMessage !== null && $lastUserMessage->getRole() === Message::ROLE_HUMAN) { - break; - } - } - if (!$lastUserMessage instanceof Message || $lastUserMessage->getRole() !== Message::ROLE_HUMAN) { - throw new NotFoundException($this->l10n->t('No human message found in this session')); - } - - $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 = self::EMPTY_CONVERSATION_TOKEN; - - // 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 (!empty($messages) && $messages[0]->getRole() === Message::ROLE_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 deleted file mode 100644 index 334a540af..000000000 --- a/lib/Service/InternalException.php +++ /dev/null @@ -1,11 +0,0 @@ -messageMapper->getMessages($session->getId(), 0, self::SUMMARY_MESSAGE_LIMIT); - if ($messages[0]->getRole() === Message::ROLE_SYSTEM) { + if ($messages[0]->getRole() === 'system') { array_shift($messages); } diff --git a/lib/Service/UnauthorizedException.php b/lib/Service/UnauthorizedException.php deleted file mode 100644 index cc1e29bed..000000000 --- a/lib/Service/UnauthorizedException.php +++ /dev/null @@ -1,10 +0,0 @@ -}, array{}>]]> - - - - - - From f18a005f69e56e29b030285715d8dec09da1d98a Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 19 May 2026 14:27:30 +0200 Subject: [PATCH 03/59] fix(z-index): correctly set the z-index depending on if we are in the viewer or not, this makes the file picker always appear on top of the assistant dialog Signed-off-by: Julien Veyssier --- src/assistant.js | 2 ++ src/components/AssistantTextProcessingModal.vue | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/assistant.js b/src/assistant.js index 24145aef4..d5b101806 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -81,6 +81,7 @@ export async function openAssistantForm({ if (isInsideViewer) { // so the assistant modal is opened on top of the current viewer document.querySelector('body').append(modalMountPoint) + modalMountPoint.classList.add('insideViewer') } else { // so the viewer can be later opened on top of the assistant document.querySelector('body').insertBefore(modalMountPoint, content.nextSibling) @@ -547,6 +548,7 @@ export async function openAssistantTask( if (isInsideViewer) { // so the assistant modal is opened on top of the current viewer document.querySelector('body').append(modalMountPoint) + modalMountPoint.classList.add('insideViewer') } else { // so the viewer can be later opened on top of the assistant document.querySelector('body').insertBefore(modalMountPoint, content.nextSibling) diff --git a/src/components/AssistantTextProcessingModal.vue b/src/components/AssistantTextProcessingModal.vue index af4825b32..58a370e3a 100644 --- a/src/components/AssistantTextProcessingModal.vue +++ b/src/components/AssistantTextProcessingModal.vue @@ -204,6 +204,13 @@ export default {