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