diff --git a/README.md b/README.md index 2a35658b..08e10f4f 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ This app brings a user interface to use the Nextcloud text processing feature. -It allows users to launch text processing tasks, be notified when they finish and see the results. +It allows users to launch AI tasks, be notified when they finish and see the results. The assistant also appears in others apps like Text to easily process parts of a document. ### How to use it -A new right header menu entry appears. Once clicked, the assistant is displayed and you can select and task type and -set the input text you want to process. +A new right header menu entry appears. Once clicked, the assistant is displayed and you can select and task type and +set the input you want to process. +The task might run immediately or be scheduled depending on the time estimation given by the AI provider. Once a task is scheduled, it will run as a background job. When it is finished, you will receive a notification from which the results can be displayed. @@ -17,10 +18,13 @@ Other apps can integrate with the assistant. For example, Text will display an i to directly select a task type to process this paragraph. Selecting a task this way will open the assistant with the task being pre-selected and the input text set. -### Text processing providers +## Features In the assistant, the list of available tasks depends on the available providers installed via other apps. -This means you have complete freedom over which service/software will actually run your text processing tasks. +This means you have complete freedom over which service/software will actually run your AI tasks. + +### Text processing + So far, the [Large language model](https://github.com/nextcloud/llm#readme) and the [OpenAi/LocalAI integration](https://apps.nextcloud.com/apps/integration_openai) apps include text processing providers to: @@ -29,3 +33,16 @@ include text processing providers to: * Generate a headline * Get an answer from a free prompt * Reformulate (OpenAi/LocalAi only) +* Context writer: Generate text with a specified style. The style can be described or provided via an example text. + +### Text to image (Image generation) + +Known providers: +* [OpenAi/LocalAI integration](https://apps.nextcloud.com/apps/integration_openai) +* [Text2Image Stable Diffusion](https://apps.nextcloud.com/apps/text2image_stablediffusion) + +### Speech to text (Audio transcription) + +Known providers: +* [OpenAi/LocalAI integration](https://apps.nextcloud.com/apps/integration_openai) +* [Local Whisper Speech-To-Text](https://apps.nextcloud.com/apps/stt_whisper) diff --git a/appinfo/info.xml b/appinfo/info.xml index 8d70b9ae..6af10a60 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -6,14 +6,15 @@ 1.0.5 agpl diff --git a/appinfo/routes.php b/appinfo/routes.php index e11714fe..324554f4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1,5 +1,9 @@ '(v1)', +]; + return [ 'routes' => [ ['name' => 'config#getConfigValue', 'url' => '/config', 'verb' => 'GET'], @@ -7,23 +11,8 @@ ['name' => 'config#setAdminConfig', 'url' => '/admin-config', 'verb' => 'PUT'], ['name' => 'assistant#getAssistantTaskResultPage', 'url' => '/task/view/{metaTaskId}', 'verb' => 'GET'], - ['name' => 'assistant#getAssistantTask', 'url' => '/task/{metaTaskId}', 'verb' => 'GET'], - ['name' => 'assistant#getUserTasks', 'url' => '/tasks', 'verb' => 'GET'], - ['name' => 'assistant#runTextProcessingTask', 'url' => '/task/run', 'verb' => 'POST'], - ['name' => 'assistant#scheduleTextProcessingTask', 'url' => '/task/schedule', 'verb' => 'POST'], - ['name' => 'assistant#runOrScheduleTextProcessingTask', 'url' => '/task/run-or-schedule', 'verb' => 'POST'], - ['name' => 'assistant#parseTextFromFile', 'url' => '/parse-file', 'verb' => 'POST'], - ['name' => 'assistant#deleteTask', 'url' => '/task/{metaTaskId}', 'verb' => 'DELETE'], - ['name' => 'assistant#cancelTask', 'url' => '/task/cancel/{metaTaskId}', 'verb' => 'PUT'], - - ['name' => 'Text2Image#processPrompt', 'url' => '/i/process_prompt', 'verb' => 'POST'], - ['name' => 'Text2Image#getPromptHistory', 'url' => '/i/prompt_history', 'verb' => 'GET'], + ['name' => 'Text2Image#showGenerationPage', 'url' => '/i/{imageGenId}', 'verb' => 'GET'], - ['name' => 'Text2Image#getGenerationInfo', 'url' => '/i/info/{imageGenId}', 'verb' => 'GET'], - ['name' => 'Text2Image#getImage', 'url' => '/i/{imageGenId}/{fileNameId}', 'verb' => 'GET'], - ['name' => 'Text2Image#cancelGeneration', 'url' => '/i/cancel_generation', 'verb' => 'POST'], - ['name' => 'Text2Image#setVisibilityOfImageFiles', 'url' => '/i/visibility/{imageGenId}', 'verb' => 'POST'], - ['name' => 'Text2Image#notifyWhenReady', 'url' => '/i/notify/{imageGenId}', 'verb' => 'POST'], ['name' => 'FreePrompt#processPrompt', 'url' => '/f/process_prompt', 'verb' => 'POST'], ['name' => 'FreePrompt#getPromptHistory', 'url' => '/f/prompt_history', 'verb' => 'GET'], @@ -31,7 +20,27 @@ ['name' => 'FreePrompt#cancelGeneration', 'url' => '/f/cancel_generation', 'verb' => 'POST'], ['name' => 'SpeechToText#getResultPage', 'url' => '/stt/result-page/{metaTaskId}', 'verb' => 'GET'], - ['name' => 'SpeechToText#transcribeAudio', 'url' => '/stt/transcribeAudio', 'verb' => 'POST'], - ['name' => 'SpeechToText#transcribeFile', 'url' => '/stt/transcribeFile', 'verb' => 'POST'], + ], + 'ocs' => [ + ['name' => 'assistantApi#getAvailableTaskTypes', 'url' => '/api/{apiVersion}/task-types', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'assistantApi#getAssistantTask', 'url' => '/api/{apiVersion}/task/{metaTaskId}', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'assistantApi#getUserTasks', 'url' => '/api/{apiVersion}/tasks', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'assistantApi#runTextProcessingTask', 'url' => '/api/{apiVersion}/task/run', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'assistantApi#scheduleTextProcessingTask', 'url' => '/api/{apiVersion}/task/schedule', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'assistantApi#runOrScheduleTextProcessingTask', 'url' => '/api/{apiVersion}/task/run-or-schedule', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'assistantApi#parseTextFromFile', 'url' => '/api/{apiVersion}/parse-file', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'assistantApi#deleteTask', 'url' => '/api/{apiVersion}/task/{metaTaskId}', 'verb' => 'DELETE', 'requirements' => $requirements], + ['name' => 'assistantApi#cancelTask', 'url' => '/api/{apiVersion}/task/cancel/{metaTaskId}', 'verb' => 'PUT', 'requirements' => $requirements], + + ['name' => 'Text2ImageApi#processPrompt', 'url' => '/api/{apiVersion}/i/process_prompt', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'Text2ImageApi#getPromptHistory', 'url' => '/api/{apiVersion}/i/prompt_history', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'Text2ImageApi#getGenerationInfo', 'url' => '/api/{apiVersion}/i/info/{imageGenId}', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'Text2ImageApi#getImage', 'url' => '/api/{apiVersion}/i/{imageGenId}/{fileNameId}', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'Text2ImageApi#cancelGeneration', 'url' => '/api/{apiVersion}/i/cancel_generation', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'Text2ImageApi#setVisibilityOfImageFiles', 'url' => '/api/{apiVersion}/i/visibility/{imageGenId}', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'Text2ImageApi#notifyWhenReady', 'url' => '/api/{apiVersion}/i/notify/{imageGenId}', 'verb' => 'POST', 'requirements' => $requirements], + + ['name' => 'SpeechToTextApi#transcribeAudio', 'url' => '/api/{apiVersion}/stt/transcribeAudio', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'SpeechToTextApi#transcribeFile', 'url' => '/api/{apiVersion}/stt/transcribeFile', 'verb' => 'POST', 'requirements' => $requirements], ], ]; diff --git a/lib/Controller/AssistantApiController.php b/lib/Controller/AssistantApiController.php new file mode 100644 index 00000000..525d60e0 --- /dev/null +++ b/lib/Controller/AssistantApiController.php @@ -0,0 +1,195 @@ +assistantService->getAvailableTaskTypes(); + return new DataResponse(['types' => $taskTypes]); + } + + /** + * @param int $metaTaskId + * @return DataResponse + */ + #[NoAdminRequired] + public function deleteTask(int $metaTaskId): DataResponse { + if ($this->userId !== null) { + try { + $this->assistantService->deleteAssistantTask($this->userId, $metaTaskId); + return new DataResponse(''); + } catch (\Exception $e) { + } + } + + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + + /** + * @param int $metaTaskId + * @return DataResponse + */ + #[NoAdminRequired] + public function cancelTask(int $metaTaskId): DataResponse { + if ($this->userId !== null) { + try { + $this->assistantService->cancelAssistantTask($this->userId, $metaTaskId); + return new DataResponse(''); + } catch (\Exception $e) { + } + } + + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + + /** + * @param int $metaTaskId + * @return DataResponse + */ + #[NoAdminRequired] + public function getAssistantTask(int $metaTaskId): DataResponse { + if ($this->userId !== null) { + $task = $this->assistantService->getAssistantTask($this->userId, $metaTaskId); + if ($task !== null) { + return new DataResponse([ + 'task' => $task->jsonSerializeCc(), + ]); + } + } + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + + #[NoAdminRequired] + public function getUserTasks(?string $taskType = null, ?int $category = null): DataResponse { + if ($this->userId !== null) { + try { + $tasks = $this->metaTaskMapper->getUserMetaTasks($this->userId, $taskType, $category); + $serializedTasks = array_map(static function (MetaTask $task) { + return $task->jsonSerializeCc(); + }, $tasks); + return new DataResponse(['tasks' => $serializedTasks]); + } catch (Exception $e) { + return new DataResponse(['tasks' => []]); + } + } + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + + /** + * @param array $inputs + * @param string $type + * @param string $appId + * @param string $identifier + * @return DataResponse + */ + #[NoAdminRequired] + public function runTextProcessingTask(string $type, array $inputs, string $appId, string $identifier): DataResponse { + if ($this->userId === null) { + return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST); + } + + try { + $task = $this->assistantService->runTextProcessingTask($type, $inputs, $appId, $this->userId, $identifier); + } catch (\Exception | \Throwable $e) { + return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); + } + return new DataResponse([ + 'task' => $task->jsonSerializeCc(), + ]); + } + + /** + * @param array $inputs + * @param string $type + * @param string $appId + * @param string $identifier + * @return DataResponse + */ + #[NoAdminRequired] + public function scheduleTextProcessingTask(string $type, array $inputs, string $appId, string $identifier): DataResponse { + if ($this->userId === null) { + return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST); + } + + try { + $task = $this->assistantService->scheduleTextProcessingTask($type, $inputs, $appId, $this->userId, $identifier); + } catch (\Exception | \Throwable $e) { + return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); + } + return new DataResponse([ + 'task' => $task->jsonSerializeCc(), + ]); + } + + /** + * @param array $inputs + * @param string $type + * @param string $appId + * @param string $identifier + * @return DataResponse + */ + #[NoAdminRequired] + public function runOrScheduleTextProcessingTask(string $type, array $inputs, string $appId, string $identifier): DataResponse { + if ($this->userId === null) { + return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST); + } + + try { + $task = $this->assistantService->runOrScheduleTextProcessingTask($type, $inputs, $appId, $this->userId, $identifier); + } catch (\Exception | \Throwable $e) { + return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); + } + return new DataResponse([ + 'task' => $task->jsonSerializeCc(), + ]); + } + + /** + * Parse text from file (if parsing the file type is supported) + * + * @param string $filePath + * @return DataResponse + */ + #[NoAdminRequired] + public function parseTextFromFile(string $filePath): DataResponse { + if ($this->userId === null) { + return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST); + } + + try { + $text = $this->assistantService->parseTextFromFile($filePath, $this->userId); + } catch (\Exception | \Throwable $e) { + return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); + } + return new DataResponse([ + 'parsedText' => $text, + ]); + } +} diff --git a/lib/Controller/AssistantController.php b/lib/Controller/AssistantController.php index 398663cb..018dea1b 100644 --- a/lib/Controller/AssistantController.php +++ b/lib/Controller/AssistantController.php @@ -3,17 +3,13 @@ namespace OCA\TpAssistant\Controller; use OCA\TpAssistant\AppInfo\Application; -use OCA\TpAssistant\Db\MetaTask; -use OCA\TpAssistant\Db\MetaTaskMapper; use OCA\TpAssistant\Service\AssistantService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; -use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; -use OCP\DB\Exception; use OCP\IRequest; class AssistantController extends Controller { @@ -22,47 +18,12 @@ public function __construct( string $appName, IRequest $request, private AssistantService $assistantService, - private MetaTaskMapper $metaTaskMapper, private IInitialState $initialStateService, private ?string $userId, ) { parent::__construct($appName, $request); } - /** - * @param int $metaTaskId - * @return DataResponse - */ - #[NoAdminRequired] - public function deleteTask(int $metaTaskId): DataResponse { - if ($this->userId !== null) { - try { - $this->assistantService->deleteAssistantTask($this->userId, $metaTaskId); - return new DataResponse(''); - } catch (\Exception $e) { - } - } - - return new DataResponse('', Http::STATUS_NOT_FOUND); - } - - /** - * @param int $metaTaskId - * @return DataResponse - */ - #[NoAdminRequired] - public function cancelTask(int $metaTaskId): DataResponse { - if ($this->userId !== null) { - try { - $this->assistantService->cancelAssistantTask($this->userId, $metaTaskId); - return new DataResponse(''); - } catch (\Exception $e) { - } - } - - return new DataResponse('', Http::STATUS_NOT_FOUND); - } - /** * @param int $metaTaskId * @return TemplateResponse @@ -79,128 +40,4 @@ public function getAssistantTaskResultPage(int $metaTaskId): TemplateResponse { } return new TemplateResponse('', '403', [], TemplateResponse::RENDER_AS_ERROR, Http::STATUS_FORBIDDEN); } - - /** - * @param int $metaTaskId - * @return DataResponse - */ - #[NoAdminRequired] - public function getAssistantTask(int $metaTaskId): DataResponse { - if ($this->userId !== null) { - $task = $this->assistantService->getAssistantTask($this->userId, $metaTaskId); - if ($task !== null) { - return new DataResponse([ - 'task' => $task->jsonSerializeCc(), - ]); - } - } - return new DataResponse('', Http::STATUS_NOT_FOUND); - } - - #[NoAdminRequired] - public function getUserTasks(?string $taskType = null, ?int $category = null): DataResponse { - if ($this->userId !== null) { - try { - $tasks = $this->metaTaskMapper->getUserMetaTasks($this->userId, $taskType, $category); - $serializedTasks = array_map(static function (MetaTask $task) { - return $task->jsonSerializeCc(); - }, $tasks); - return new DataResponse(['tasks' => $serializedTasks]); - } catch (Exception $e) { - return new DataResponse(['tasks' => []]); - } - } - return new DataResponse('', Http::STATUS_NOT_FOUND); - } - - /** - * @param array $inputs - * @param string $type - * @param string $appId - * @param string $identifier - * @return DataResponse - */ - #[NoAdminRequired] - public function runTextProcessingTask(string $type, array $inputs, string $appId, string $identifier): DataResponse { - if ($this->userId === null) { - return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST); - } - - try { - $task = $this->assistantService->runTextProcessingTask($type, $inputs, $appId, $this->userId, $identifier); - } catch (\Exception | \Throwable $e) { - return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); - } - return new DataResponse([ - 'task' => $task->jsonSerializeCc(), - ]); - } - - /** - * @param array $inputs - * @param string $type - * @param string $appId - * @param string $identifier - * @return DataResponse - */ - #[NoAdminRequired] - public function scheduleTextProcessingTask(string $type, array $inputs, string $appId, string $identifier): DataResponse { - if ($this->userId === null) { - return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST); - } - - try { - $task = $this->assistantService->scheduleTextProcessingTask($type, $inputs, $appId, $this->userId, $identifier); - } catch (\Exception | \Throwable $e) { - return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); - } - return new DataResponse([ - 'task' => $task->jsonSerializeCc(), - ]); - } - - /** - * @param array $inputs - * @param string $type - * @param string $appId - * @param string $identifier - * @return DataResponse - */ - #[NoAdminRequired] - public function runOrScheduleTextProcessingTask(string $type, array $inputs, string $appId, string $identifier): DataResponse { - if ($this->userId === null) { - return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST); - } - - try { - $task = $this->assistantService->runOrScheduleTextProcessingTask($type, $inputs, $appId, $this->userId, $identifier); - } catch (\Exception | \Throwable $e) { - return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); - } - return new DataResponse([ - 'task' => $task->jsonSerializeCc(), - ]); - } - - /** - * Parse text from file (if parsing the file type is supported) - * - * @param string $filePath - * @return DataResponse - */ - #[NoAdminRequired] - public function parseTextFromFile(string $filePath): DataResponse { - if ($this->userId === null) { - return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST); - } - - try { - $text = $this->assistantService->parseTextFromFile($filePath, $this->userId); - } catch (\Exception | \Throwable $e) { - return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); - } - return new DataResponse([ - 'parsedText' => $text, - ]); - } } diff --git a/lib/Controller/SpeechToTextApiController.php b/lib/Controller/SpeechToTextApiController.php new file mode 100644 index 00000000..ea7ca42c --- /dev/null +++ b/lib/Controller/SpeechToTextApiController.php @@ -0,0 +1,169 @@ + + * + * @author Anupam Kumar + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\TpAssistant\Controller; + +use Exception; +use InvalidArgumentException; +use OCA\TpAssistant\AppInfo\Application; +use OCA\TpAssistant\Service\SpeechToText\SpeechToTextService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\PreConditionNotMetException; +use Psr\Log\LoggerInterface; +use RuntimeException; + +class SpeechToTextApiController extends OCSController { + + public function __construct( + string $appName, + IRequest $request, + private SpeechToTextService $service, + private LoggerInterface $logger, + private IL10N $l10n, + private ?string $userId, + ) { + parent::__construct($appName, $request); + } + + /** + * @param int $id Transcript ID + * @return DataResponse + */ + #[NoAdminRequired] + public function getTranscript(int $id): DataResponse { + if ($this->userId === null) { + return new DataResponse('', Http::STATUS_UNAUTHORIZED); + } + try { + return new DataResponse($this->service->internalGetTask($this->userId, $id)->getOutput()); + } catch (Exception $e) { + return new DataResponse($e->getMessage(), intval($e->getCode())); + } + } + + + /** + * @return DataResponse + * @throws NotPermittedException + */ + #[NoAdminRequired] + public function transcribeAudio(): DataResponse { + $audioData = $this->request->getUploadedFile('audioData'); + + if ($audioData['error'] !== 0) { + return new DataResponse('Error in audio file upload: ' . $audioData['error'], Http::STATUS_BAD_REQUEST); + } + + if (empty($audioData)) { + return new DataResponse('Invalid audio data received', Http::STATUS_BAD_REQUEST); + } + + if ($audioData['type'] !== 'audio/mp3' && $audioData['type'] !== 'audio/mpeg') { + return new DataResponse('Audio file must be in MP3 format', Http::STATUS_BAD_REQUEST); + } + + try { + $this->service->transcribeAudio($audioData['tmp_name'], $this->userId); + return new DataResponse('ok'); + } catch (RuntimeException $e) { + $this->logger->error( + 'Runtime exception: ' . $e->getMessage(), + ['app' => Application::APP_ID] + ); + return new DataResponse( + $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } catch (PreConditionNotMetException $e) { + $this->logger->error('No Speech-to-Text provider found: ' . $e->getMessage(), ['app' => Application::APP_ID]); + return new DataResponse( + $this->l10n->t('No Speech-to-Text provider found, install one from the app store to use this feature.'), + Http::STATUS_BAD_REQUEST + ); + } catch (InvalidArgumentException $e) { + $this->logger->error('InvalidArgumentException: ' . $e->getMessage(), ['app' => Application::APP_ID]); + return new DataResponse( + $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } + + /** + * @param string $path Nextcloud file path + * @return DataResponse + */ + #[NoAdminRequired] + public function transcribeFile(string $path): DataResponse { + if ($path === '') { + return new DataResponse('Empty file path received', Http::STATUS_BAD_REQUEST); + } + + try { + $this->service->transcribeFile($path, $this->userId); + return new DataResponse('ok'); + } catch (NotFoundException $e) { + $this->logger->error('Audio file not found: ' . $e->getMessage(), ['app' => Application::APP_ID]); + return new DataResponse( + $this->l10n->t('Audio file not found.'), + Http::STATUS_NOT_FOUND + ); + } catch (RuntimeException $e) { + $this->logger->error( + 'Runtime exception: ' . $e->getMessage(), + ['app' => Application::APP_ID] + ); + return new DataResponse( + $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } catch (NotPermittedException $e) { + $this->logger->error( + 'No permission to create recording file/directory: ' . $e->getMessage(), + ['app' => Application::APP_ID] + ); + return new DataResponse( + $this->l10n->t('No permission to create recording file/directory, contact your sysadmin to resolve this issue.'), + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } catch (PreConditionNotMetException $e) { + $this->logger->error('No Speech-to-Text provider found: ' . $e->getMessage(), ['app' => Application::APP_ID]); + return new DataResponse( + $this->l10n->t('No Speech-to-Text provider found, install one from the app store to use this feature.'), + Http::STATUS_BAD_REQUEST + ); + } catch (InvalidArgumentException $e) { + $this->logger->error('InvalidArgumentException: ' . $e->getMessage(), ['app' => Application::APP_ID]); + return new DataResponse( + $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/lib/Controller/SpeechToTextController.php b/lib/Controller/SpeechToTextController.php index 03365162..3fae1da4 100644 --- a/lib/Controller/SpeechToTextController.php +++ b/lib/Controller/SpeechToTextController.php @@ -23,39 +23,24 @@ namespace OCA\TpAssistant\Controller; use Exception; -use InvalidArgumentException; use OCA\TpAssistant\AppInfo\Application; -use OCA\TpAssistant\Db\MetaTask; -use OCA\TpAssistant\Db\MetaTaskMapper; use OCA\TpAssistant\Service\SpeechToText\SpeechToTextService; use OCP\AppFramework\Controller; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; -use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; -use OCP\Files\NotFoundException; -use OCP\Files\NotPermittedException; -use OCP\IL10N; use OCP\IRequest; -use OCP\PreConditionNotMetException; -use Psr\Log\LoggerInterface; -use RuntimeException; class SpeechToTextController extends Controller { public function __construct( - string $appName, - IRequest $request, + string $appName, + IRequest $request, private SpeechToTextService $service, - private LoggerInterface $logger, - private IL10N $l10n, - private IInitialState $initialState, - private ?string $userId, - private MetaTaskMapper $metaTaskMapper, + private IInitialState $initialState, + private ?string $userId, ) { parent::__construct($appName, $request); } @@ -67,10 +52,13 @@ public function __construct( #[NoAdminRequired] #[NoCSRFRequired] public function getResultPage(int $metaTaskId): TemplateResponse { + if ($this->userId === null) { + return new TemplateResponse('', '403', [], TemplateResponse::RENDER_AS_ERROR, Http::STATUS_FORBIDDEN); + } $response = new TemplateResponse(Application::APP_ID, 'speechToTextResultPage'); try { $initData = [ - 'task' => $this->internalGetTask($metaTaskId), + 'task' => $this->service->internalGetTask($this->userId, $metaTaskId), ]; } catch (Exception $e) { $initData = [ @@ -83,146 +71,4 @@ public function getResultPage(int $metaTaskId): TemplateResponse { $this->initialState->provideInitialState('plain-text-result', $initData); return $response; } - - /** - * @param int $id Transcript ID - * @return DataResponse - */ - #[NoAdminRequired] - public function getTranscript(int $id): DataResponse { - try { - return new DataResponse($this->internalGetTask($id)->getOutput()); - } catch (Exception $e) { - return new DataResponse($e->getMessage(), intval($e->getCode())); - } - } - - /** - * Internal function to get transcription assistant tasks based on the assistant meta task id - * - * @param integer $id - * @return MetaTask - * @throws Exception - */ - private function internalGetTask(int $id): MetaTask { - try { - $metaTask = $this->metaTaskMapper->getUserMetaTask($id, $this->userId); - - if($metaTask->getCategory() !== Application::TASK_CATEGORY_SPEECH_TO_TEXT) { - throw new Exception('Task is not a speech to text task.', Http::STATUS_BAD_REQUEST); - } - - return $metaTask; - } catch (MultipleObjectsReturnedException $e) { - $this->logger->error('Multiple tasks found for one id: ' . $e->getMessage(), ['app' => Application::APP_ID]); - throw new Exception($this->l10n->t('Multiple tasks found'), Http::STATUS_BAD_REQUEST); - } catch (DoesNotExistException $e) { - throw new Exception($this->l10n->t('Transcript not found'), Http::STATUS_NOT_FOUND); - } catch (Exception $e) { - $this->logger->error('Error: ' . $e->getMessage(), ['app' => Application::APP_ID]); - throw new Exception( - $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), - Http::STATUS_INTERNAL_SERVER_ERROR, - ); - } - } - - /** - * @return DataResponse - * @throws NotPermittedException - */ - #[NoAdminRequired] - public function transcribeAudio(): DataResponse { - $audioData = $this->request->getUploadedFile('audioData'); - - if ($audioData['error'] !== 0) { - return new DataResponse('Error in audio file upload: ' . $audioData['error'], Http::STATUS_BAD_REQUEST); - } - - if (empty($audioData)) { - return new DataResponse('Invalid audio data received', Http::STATUS_BAD_REQUEST); - } - - if ($audioData['type'] !== 'audio/mp3' && $audioData['type'] !== 'audio/mpeg') { - return new DataResponse('Audio file must be in MP3 format', Http::STATUS_BAD_REQUEST); - } - - try { - $this->service->transcribeAudio($audioData['tmp_name'], $this->userId); - return new DataResponse('ok'); - } catch (RuntimeException $e) { - $this->logger->error( - 'Runtime exception: ' . $e->getMessage(), - ['app' => Application::APP_ID] - ); - return new DataResponse( - $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), - Http::STATUS_INTERNAL_SERVER_ERROR - ); - } catch (PreConditionNotMetException $e) { - $this->logger->error('No Speech-to-Text provider found: ' . $e->getMessage(), ['app' => Application::APP_ID]); - return new DataResponse( - $this->l10n->t('No Speech-to-Text provider found, install one from the app store to use this feature.'), - Http::STATUS_BAD_REQUEST - ); - } catch (InvalidArgumentException $e) { - $this->logger->error('InvalidArgumentException: ' . $e->getMessage(), ['app' => Application::APP_ID]); - return new DataResponse( - $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), - Http::STATUS_INTERNAL_SERVER_ERROR - ); - } - } - - /** - * @param string $path Nextcloud file path - * @return DataResponse - */ - #[NoAdminRequired] - public function transcribeFile(string $path): DataResponse { - if ($path === '') { - return new DataResponse('Empty file path received', Http::STATUS_BAD_REQUEST); - } - - try { - $this->service->transcribeFile($path, $this->userId); - return new DataResponse('ok'); - } catch (NotFoundException $e) { - $this->logger->error('Audio file not found: ' . $e->getMessage(), ['app' => Application::APP_ID]); - return new DataResponse( - $this->l10n->t('Audio file not found.'), - Http::STATUS_NOT_FOUND - ); - } catch (RuntimeException $e) { - $this->logger->error( - 'Runtime exception: ' . $e->getMessage(), - ['app' => Application::APP_ID] - ); - return new DataResponse( - $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), - Http::STATUS_INTERNAL_SERVER_ERROR - ); - } catch (NotPermittedException $e) { - $this->logger->error( - 'No permission to create recording file/directory: ' . $e->getMessage(), - ['app' => Application::APP_ID] - ); - return new DataResponse( - $this->l10n->t('No permission to create recording file/directory, contact your sysadmin to resolve this issue.'), - Http::STATUS_INTERNAL_SERVER_ERROR - ); - } catch (PreConditionNotMetException $e) { - $this->logger->error('No Speech-to-Text provider found: ' . $e->getMessage(), ['app' => Application::APP_ID]); - return new DataResponse( - $this->l10n->t('No Speech-to-Text provider found, install one from the app store to use this feature.'), - Http::STATUS_BAD_REQUEST - ); - } catch (InvalidArgumentException $e) { - $this->logger->error('InvalidArgumentException: ' . $e->getMessage(), ['app' => Application::APP_ID]); - return new DataResponse( - $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), - Http::STATUS_INTERNAL_SERVER_ERROR - ); - } - } } diff --git a/lib/Controller/Text2ImageApiController.php b/lib/Controller/Text2ImageApiController.php new file mode 100644 index 00000000..67daf89b --- /dev/null +++ b/lib/Controller/Text2ImageApiController.php @@ -0,0 +1,220 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\TpAssistant\Controller; + +use Exception; +use OCA\TpAssistant\Service\Text2Image\Text2ImageHelperService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\DataDisplayResponse; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\Db\Exception as DbException; + +use OCP\Files\NotPermittedException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\TextToImage\Exception\TaskFailureException; + +class Text2ImageApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private Text2ImageHelperService $text2ImageHelperService, + private ?string $userId, + private IL10N $l10n, + ) { + parent::__construct($appName, $request); + } + + /** + * @param string $appId + * @param string $identifier + * @param string $prompt + * @param int $nResults + * @param bool $displayPrompt + * @param bool $notifyReadyIfScheduled + * @param bool $schedule + * @return DataResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function processPrompt( + string $appId, string $identifier, string $prompt, int $nResults = 1, bool $displayPrompt = false, + bool $notifyReadyIfScheduled = false, bool $schedule = false + ): DataResponse { + $nResults = min(10, max(1, $nResults)); + try { + $result = $this->text2ImageHelperService->processPrompt( + $appId, $identifier, $prompt, $nResults, $displayPrompt, $this->userId, $notifyReadyIfScheduled, $schedule + ); + } catch (Exception | TaskFailureException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse($result); + } + + /** + * @return DataResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function getPromptHistory(): DataResponse { + + if ($this->userId === null) { + return new DataResponse(['error' => $this->l10n->t('Failed to get prompt history; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + try { + $response = $this->text2ImageHelperService->getPromptHistory($this->userId); + } catch (DbException $e) { + return new DataResponse(['error' => $this->l10n->t('Unknown error while retrieving prompt history.')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new DataResponse($response); + } + + /** + * @param string $imageGenId + * @param int $fileNameId + * @return DataDisplayResponse | DataResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'imageGenId')] + public function getImage(string $imageGenId, int $fileNameId): DataDisplayResponse | DataResponse { + try { + $result = $this->text2ImageHelperService->getImage($imageGenId, $fileNameId); + } catch (Exception $e) { + $response = new DataResponse(['error' => $e->getMessage()], (int) $e->getCode()); + if ($e->getCode() === Http::STATUS_BAD_REQUEST || $e->getCode() === Http::STATUS_UNAUTHORIZED) { + // Throttle brute force attempts + $response->throttle(['imageGenId' => $imageGenId, 'fileId' => $fileNameId, 'status' => $e->getCode()]); + } + return $response; + } + + /* + if (isset($result['processing'])) { + return new DataResponse($result, Http::STATUS_OK); + } + */ + + $response = new DataDisplayResponse( + $result['image'] ?? '', + Http::STATUS_OK, + ['Content-Type' => $result['content-type'] ?? 'image/jpeg'] + ); + $response->cacheFor(60 * 60 * 24); + return $response; + } + + /** + * @param string $imageGenId + * @return DataResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'imageGenId')] + public function getGenerationInfo(string $imageGenId): DataResponse { + try { + $result = $this->text2ImageHelperService->getGenerationInfo($imageGenId, $this->userId, true); + } catch (Exception $e) { + $response = new DataResponse(['error' => $e->getMessage()], (int) $e->getCode()); + if ($e->getCode() === Http::STATUS_BAD_REQUEST || $e->getCode() === Http::STATUS_UNAUTHORIZED) { + // Throttle brute force attempts + $response->throttle(['imageGenId' => $imageGenId, 'status' => $e->getCode()]); + } + return $response; + } + + return new DataResponse($result, Http::STATUS_OK); + } + + /** + * @param string $imageGenId + * @param array $fileVisStatusArray + * @return DataResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[BruteForceProtection(action: 'imageGenId')] + public function setVisibilityOfImageFiles(string $imageGenId, array $fileVisStatusArray): DataResponse { + if ($this->userId === null) { + return new DataResponse(['error' => $this->l10n->t('Failed to set visibility of image files; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if (count($fileVisStatusArray) < 1) { + return new DataResponse('File visibility array empty', Http::STATUS_BAD_REQUEST); + } + + try { + $this->text2ImageHelperService->setVisibilityOfImageFiles($imageGenId, $fileVisStatusArray, $this->userId); + } catch (Exception $e) { + $response = new DataResponse(['error' => $e->getMessage()], (int) $e->getCode()); + if($e->getCode() === Http::STATUS_BAD_REQUEST || $e->getCode() === Http::STATUS_UNAUTHORIZED) { + // Throttle brute force attempts + $response->throttle(['imageGenId' => $imageGenId, 'status' => $e->getCode()]); + } + return $response; + } + + return new DataResponse('success', Http::STATUS_OK); + } + + /** + * Notify when image generation is ready + * + * Does not need bruteforce protection since we respond with success anyways + * as we don't want to keep the front-end waiting. + * However, we still use rate limiting to prevent timing attacks. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 10, period: 60)] + public function notifyWhenReady(string $imageGenId): DataResponse { + if ($this->userId === null) { + return new DataResponse(['error' => $this->l10n->t('Failed to notify when ready; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + try { + $this->text2ImageHelperService->notifyWhenReady($imageGenId, $this->userId); + } catch (Exception $e) { + // Ignore + } + return new DataResponse('success', Http::STATUS_OK); + } + + /** + * Cancel image generation + * + * Does not need bruteforce protection since we respond with success anyways + * (In theory bruteforce may be possible by a response timing attack but the attacker + * won't gain access to the generation since its deleted during the attack.) + * + * @param string $imageGenId + * @return DataResponse + * @throws NotPermittedException + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 10, period: 60)] + public function cancelGeneration(string $imageGenId): DataResponse { + if ($this->userId === null) { + return new DataResponse(['error' => $this->l10n->t('Failed to cancel generation; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + $this->text2ImageHelperService->cancelGeneration($imageGenId, $this->userId); + return new DataResponse('success', Http::STATUS_OK); + } +} diff --git a/lib/Controller/Text2ImageController.php b/lib/Controller/Text2ImageController.php index 5fdb48cf..599ca53a 100644 --- a/lib/Controller/Text2ImageController.php +++ b/lib/Controller/Text2ImageController.php @@ -5,223 +5,24 @@ namespace OCA\TpAssistant\Controller; -use Exception; use OCA\TpAssistant\AppInfo\Application; -use OCA\TpAssistant\Service\Text2Image\Text2ImageHelperService; use OCP\AppFramework\Controller; -use OCP\AppFramework\Http; -use OCP\AppFramework\Http\Attribute\AnonRateLimit; -use OCP\AppFramework\Http\Attribute\BruteForceProtection; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; -use OCP\AppFramework\Http\DataDisplayResponse; -use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; -use OCP\Db\Exception as DbException; - -use OCP\Files\NotPermittedException; -use OCP\IL10N; use OCP\IRequest; -use OCP\TextToImage\Exception\TaskFailureException; class Text2ImageController extends Controller { public function __construct( string $appName, IRequest $request, - private Text2ImageHelperService $text2ImageHelperService, private IInitialState $initialStateService, - private ?string $userId, - private IL10N $l10n, ) { parent::__construct($appName, $request); } - /** - * @param string $appId - * @param string $identifier - * @param string $prompt - * @param int $nResults - * @param bool $displayPrompt - * @param bool $notifyReadyIfScheduled - * @param bool $schedule - * @return DataResponse - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function processPrompt( - string $appId, string $identifier, string $prompt, int $nResults = 1, bool $displayPrompt = false, - bool $notifyReadyIfScheduled = false, bool $schedule = false - ): DataResponse { - $nResults = min(10, max(1, $nResults)); - try { - $result = $this->text2ImageHelperService->processPrompt( - $appId, $identifier, $prompt, $nResults, $displayPrompt, $this->userId, $notifyReadyIfScheduled, $schedule - ); - } catch (Exception | TaskFailureException $e) { - return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - - return new DataResponse($result); - } - - /** - * @return DataResponse - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function getPromptHistory(): DataResponse { - - if ($this->userId === null) { - return new DataResponse(['error' => $this->l10n->t('Failed to get prompt history; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - try { - $response = $this->text2ImageHelperService->getPromptHistory($this->userId); - } catch (DbException $e) { - return new DataResponse(['error' => $this->l10n->t('Unknown error while retrieving prompt history.')], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - return new DataResponse($response); - } - - /** - * @param string $imageGenId - * @param int $fileNameId - * @return DataDisplayResponse | DataResponse - */ - #[NoAdminRequired] - #[NoCSRFRequired] - #[PublicPage] - #[BruteForceProtection(action: 'imageGenId')] - public function getImage(string $imageGenId, int $fileNameId): DataDisplayResponse | DataResponse { - try { - $result = $this->text2ImageHelperService->getImage($imageGenId, $fileNameId); - } catch (Exception $e) { - $response = new DataResponse(['error' => $e->getMessage()], (int) $e->getCode()); - if ($e->getCode() === Http::STATUS_BAD_REQUEST || $e->getCode() === Http::STATUS_UNAUTHORIZED) { - // Throttle brute force attempts - $response->throttle(['imageGenId' => $imageGenId, 'fileId' => $fileNameId, 'status' => $e->getCode()]); - } - return $response; - } - - /* - if (isset($result['processing'])) { - return new DataResponse($result, Http::STATUS_OK); - } - */ - - $response = new DataDisplayResponse( - $result['image'] ?? '', - Http::STATUS_OK, - ['Content-Type' => $result['content-type'] ?? 'image/jpeg'] - ); - $response->cacheFor(60 * 60 * 24); - return $response; - } - - /** - * @param string $imageGenId - * @return DataResponse - */ - #[NoAdminRequired] - #[NoCSRFRequired] - #[PublicPage] - #[BruteForceProtection(action: 'imageGenId')] - public function getGenerationInfo(string $imageGenId): DataResponse { - try { - $result = $this->text2ImageHelperService->getGenerationInfo($imageGenId, $this->userId, true); - } catch (Exception $e) { - $response = new DataResponse(['error' => $e->getMessage()], (int) $e->getCode()); - if ($e->getCode() === Http::STATUS_BAD_REQUEST || $e->getCode() === Http::STATUS_UNAUTHORIZED) { - // Throttle brute force attempts - $response->throttle(['imageGenId' => $imageGenId, 'status' => $e->getCode()]); - } - return $response; - } - - return new DataResponse($result, Http::STATUS_OK); - } - - /** - * @param string $imageGenId - * @param array $fileVisStatusArray - * @return DataResponse - */ - #[NoAdminRequired] - #[NoCSRFRequired] - #[BruteForceProtection(action: 'imageGenId')] - public function setVisibilityOfImageFiles(string $imageGenId, array $fileVisStatusArray): DataResponse { - if ($this->userId === null) { - return new DataResponse(['error' => $this->l10n->t('Failed to set visibility of image files; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - if (count($fileVisStatusArray) < 1) { - return new DataResponse('File visibility array empty', Http::STATUS_BAD_REQUEST); - } - - try { - $this->text2ImageHelperService->setVisibilityOfImageFiles($imageGenId, $fileVisStatusArray, $this->userId); - } catch (Exception $e) { - $response = new DataResponse(['error' => $e->getMessage()], (int) $e->getCode()); - if($e->getCode() === Http::STATUS_BAD_REQUEST || $e->getCode() === Http::STATUS_UNAUTHORIZED) { - // Throttle brute force attempts - $response->throttle(['imageGenId' => $imageGenId, 'status' => $e->getCode()]); - } - return $response; - } - - return new DataResponse('success', Http::STATUS_OK); - } - - /** - * Notify when image generation is ready - * - * Does not need bruteforce protection since we respond with success anyways - * as we don't want to keep the front-end waiting. - * However, we still use rate limiting to prevent timing attacks. - */ - #[NoAdminRequired] - #[NoCSRFRequired] - #[AnonRateLimit(limit: 10, period: 60)] - public function notifyWhenReady(string $imageGenId): DataResponse { - if ($this->userId === null) { - return new DataResponse(['error' => $this->l10n->t('Failed to notify when ready; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - try { - $this->text2ImageHelperService->notifyWhenReady($imageGenId, $this->userId); - } catch (Exception $e) { - // Ignore - } - return new DataResponse('success', Http::STATUS_OK); - } - - /** - * Cancel image generation - * - * Does not need bruteforce protection since we respond with success anyways - * (In theory bruteforce may be possible by a response timing attack but the attacker - * won't gain access to the generation since its deleted during the attack.) - * - * @param string $imageGenId - * @return DataResponse - * @throws NotPermittedException - */ - #[NoAdminRequired] - #[NoCSRFRequired] - #[AnonRateLimit(limit: 10, period: 60)] - public function cancelGeneration(string $imageGenId): DataResponse { - if ($this->userId === null) { - return new DataResponse(['error' => $this->l10n->t('Failed to cancel generation; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - $this->text2ImageHelperService->cancelGeneration($imageGenId, $this->userId); - return new DataResponse('success', Http::STATUS_OK); - } - /** * Show visibility dialog * diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index 68a63307..4ec949ec 100644 --- a/lib/Service/AssistantService.php +++ b/lib/Service/AssistantService.php @@ -21,11 +21,16 @@ use OCP\IL10N; use OCP\Lock\LockedException; use OCP\PreConditionNotMetException; +use OCP\SpeechToText\ISpeechToTextManager; use OCP\TextProcessing\FreePromptTaskType; use OCP\TextProcessing\IManager as ITextProcessingManager; +use OCP\TextProcessing\ITaskType; use OCP\TextProcessing\Task as TextProcessingTask; use Parsedown; use PhpOffice\PhpWord\IOFactory; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; use RuntimeException; @@ -33,15 +38,72 @@ class AssistantService { public function __construct( private ITextProcessingManager $textProcessingManager, + private ISpeechToTextManager $speechToTextManager, private Text2ImageHelperService $text2ImageHelperService, private MetaTaskMapper $metaTaskMapper, private LoggerInterface $logger, private IRootFolder $storage, private IJobList $jobList, private IL10N $l10n, + private ContainerInterface $container, ) { } + /** + * @return array + */ + public function getAvailableTaskTypes(): array { + // text processing and copywriter + $typeClasses = $this->textProcessingManager->getAvailableTaskTypes(); + $types = []; + /** @var string $typeClass */ + foreach ($typeClasses as $typeClass) { + try { + /** @var ITaskType $object */ + $object = $this->container->get($typeClass); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { + $this->logger->warning('Could not find ' . $typeClass, ['exception' => $e]); + continue; + } + if ($typeClass === FreePromptTaskType::class) { + $types[] = [ + 'id' => $typeClass, + 'name' => $this->l10n->t('Generate text'), + 'description' => $this->l10n->t('Send a request to the Assistant, for example: write a first draft of a presentation, give me suggestions for a presentation, write a draft reply to my colleague.'), + ]; + $types[] = [ + 'id' => 'copywriter', + 'name' => $this->l10n->t('Context write'), + 'description' => $this->l10n->t('Writes text in a given style based on the provided source material.'), + ]; + } else { + $types[] = [ + 'id' => $typeClass, + 'name' => $object->getName(), + 'description' => $object->getDescription(), + ]; + } + } + + // STT + if ($this->speechToTextManager->hasProviders()) { + $types[] = [ + 'id' => 'speech-to-text', + 'name' => $this->l10n->t('Transcribe'), + 'description' => $this->l10n->t('Transcribe audio to text'), + ]; + } + // text2image + if ($this->text2ImageHelperService->hasProviders()) { + $types[] = [ + 'id' => 'OCP\TextToImage\Task', + 'name' => $this->l10n->t('Generate image'), + 'description' => $this->l10n->t('Generate an image from a text'), + ]; + } + return $types; + } + /** * @param string $writingStyle * @param string $sourceMaterial diff --git a/lib/Service/SpeechToText/SpeechToTextService.php b/lib/Service/SpeechToText/SpeechToTextService.php index 8494a2e6..99a6f3dc 100644 --- a/lib/Service/SpeechToText/SpeechToTextService.php +++ b/lib/Service/SpeechToText/SpeechToTextService.php @@ -23,17 +23,24 @@ namespace OCA\TpAssistant\Service\SpeechToText; use DateTime; +use Exception; use InvalidArgumentException; use OCA\TpAssistant\AppInfo\Application; +use OCA\TpAssistant\Db\MetaTask; use OCA\TpAssistant\Db\MetaTaskMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Http; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IConfig; +use OCP\IL10N; use OCP\PreConditionNotMetException; use OCP\SpeechToText\ISpeechToTextManager; +use Psr\Log\LoggerInterface; use RuntimeException; class SpeechToTextService { @@ -43,9 +50,42 @@ public function __construct( private IRootFolder $rootFolder, private IConfig $config, private MetaTaskMapper $metaTaskMapper, + private IL10N $l10n, + private LoggerInterface $logger, ) { } + /** + * Internal function to get transcription assistant tasks based on the assistant meta task id + * + * @param string $userId + * @param integer $metaTaskId + * @return MetaTask + * @throws Exception + */ + public function internalGetTask(string $userId, int $metaTaskId): MetaTask { + try { + $metaTask = $this->metaTaskMapper->getUserMetaTask($metaTaskId, $userId); + + if($metaTask->getCategory() !== Application::TASK_CATEGORY_SPEECH_TO_TEXT) { + throw new Exception('Task is not a speech to text task.', Http::STATUS_BAD_REQUEST); + } + + return $metaTask; + } catch (MultipleObjectsReturnedException $e) { + $this->logger->error('Multiple tasks found for one id: ' . $e->getMessage(), ['app' => Application::APP_ID]); + throw new Exception($this->l10n->t('Multiple tasks found'), Http::STATUS_BAD_REQUEST); + } catch (DoesNotExistException $e) { + throw new Exception($this->l10n->t('Transcript not found'), Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + $this->logger->error('Error: ' . $e->getMessage(), ['app' => Application::APP_ID]); + throw new Exception( + $this->l10n->t('Some internal error occurred. Contact your sysadmin for more info.'), + Http::STATUS_INTERNAL_SERVER_ERROR, + ); + } + } + /** * @param string $path * @param string|null $userId diff --git a/lib/Service/Text2Image/Text2ImageHelperService.php b/lib/Service/Text2Image/Text2ImageHelperService.php index daa2cd31..2985d0d7 100644 --- a/lib/Service/Text2Image/Text2ImageHelperService.php +++ b/lib/Service/Text2Image/Text2ImageHelperService.php @@ -56,6 +56,10 @@ public function __construct( ) { } + public function hasProviders(): bool { + return $this->textToImageManager->hasProviders(); + } + /** * Process a prompt using ImageProcessingProvider and return a link to the generated image(s) * diff --git a/src/assistant.js b/src/assistant.js index 6048d50e..04acc06d 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -86,7 +86,7 @@ export async function openAssistantTextProcessingForm({ .then(async (response) => { view.inputs = data.inputs view.showScheduleConfirmation = true - const task = response.data?.task + const task = response.data?.ocs?.data?.task lastTask = task useMetaTasks ? resolve(task) : resolve(await resolveMetaTaskToOcpTask(task)) }) @@ -115,7 +115,7 @@ export async function openAssistantTextProcessingForm({ : runOrScheduleTask runOrScheduleFunction(appId, newTaskIdentifier, taskTypeId, inputs) .then(async (response) => { - const task = response.data?.task + const task = response.data?.ocs?.data?.task lastTask = task useMetaTasks ? resolve(task) : resolve(await resolveMetaTaskToOcpTask(task)) view.inputs = task.inputs @@ -137,7 +137,7 @@ export async function openAssistantTextProcessingForm({ } else { view.$destroy() console.error('Assistant sync run error', error) - showError(t('assistant', 'Assistant error') + ': ' + error?.response?.data) + showError(t('assistant', 'Assistant error')) reject(new Error('Assistant sync run error')) } }) @@ -166,14 +166,14 @@ export async function openAssistantTextProcessingForm({ .then(async (response) => { view.showSyncTaskRunning = false view.showScheduleConfirmation = true - const task = response.data?.task + const task = response.data?.ocs?.data?.task lastTask = task useMetaTasks ? resolve(task) : resolve(await resolveMetaTaskToOcpTask(task)) }) .catch(error => { view.$destroy() console.error('Assistant scheduling error', error) - showError(t('assistant', 'Assistant error') + ': ' + error?.response?.data) + showError(t('assistant', 'Assistant error')) reject(new Error('Assistant scheduling error')) }) }) @@ -189,14 +189,14 @@ export async function openAssistantTextProcessingForm({ export async function runSttTask(inputs) { const { default: axios } = await import(/* webpackChunkName: "axios-lazy" */'@nextcloud/axios') - const { generateUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') + const { generateOcsUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') saveLastSelectedTaskType('speech-to-text') if (inputs.sttMode === 'choose') { - const url = generateUrl('/apps/assistant/stt/transcribeFile') + const url = generateOcsUrl('/apps/assistant/api/v1/stt/transcribeFile') const params = { path: inputs.audioFilePath } return axios.post(url, params) } else { - const url = generateUrl('/apps/assistant/stt/transcribeAudio') + const url = generateOcsUrl('/apps/assistant/api/v1/stt/transcribeAudio') const formData = new FormData() formData.append('audioData', inputs.audioData) return axios.post(url, formData) @@ -210,7 +210,7 @@ export function scheduleTtiTask(appId, identifier, taskType, inputs) { export async function runOrScheduleTtiTask(appId, identifier, taskType, inputs, schedule = false) { window.assistantAbortController = new AbortController() const { default: axios } = await import(/* webpackChunkName: "axios-lazy" */'@nextcloud/axios') - const { generateUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') + const { generateOcsUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') saveLastSelectedTaskType('OCP\\TextToImage\\Task') const params = { appId, @@ -221,7 +221,7 @@ export async function runOrScheduleTtiTask(appId, identifier, taskType, inputs, notifyReadyIfScheduled: true, schedule, } - const url = generateUrl('/apps/assistant/i/process_prompt') + const url = generateOcsUrl('/apps/assistant/api/v1/i/process_prompt') return axios.post(url, params, { signal: window.assistantAbortController.signal }) } @@ -251,9 +251,9 @@ export async function cancelCurrentSyncTask() { export async function runTask(appId, identifier, taskType, inputs) { window.assistantAbortController = new AbortController() const { default: axios } = await import(/* webpackChunkName: "axios-lazy" */'@nextcloud/axios') - const { generateUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') + const { generateOcsUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') saveLastSelectedTaskType(taskType) - const url = generateUrl('/apps/assistant/task/run') + const url = generateOcsUrl('/apps/assistant/api/v1/task/run') const params = { inputs, type: taskType, @@ -266,9 +266,9 @@ export async function runTask(appId, identifier, taskType, inputs) { export async function runOrScheduleTask(appId, identifier, taskType, inputs) { window.assistantAbortController = new AbortController() const { default: axios } = await import(/* webpackChunkName: "axios-lazy" */'@nextcloud/axios') - const { generateUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') + const { generateOcsUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') saveLastSelectedTaskType(taskType) - const url = generateUrl('/apps/assistant/task/run-or-schedule') + const url = generateOcsUrl('/apps/assistant/api/v1/task/run-or-schedule') const params = { inputs, type: taskType, @@ -289,9 +289,9 @@ export async function runOrScheduleTask(appId, identifier, taskType, inputs) { */ export async function scheduleTask(appId, identifier, taskType, inputs) { const { default: axios } = await import(/* webpackChunkName: "axios-lazy" */'@nextcloud/axios') - const { generateUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') + const { generateOcsUrl } = await import(/* webpackChunkName: "router-gen-lazy" */'@nextcloud/router') saveLastSelectedTaskType(taskType) - const url = generateUrl('/apps/assistant/task/schedule') + const url = generateOcsUrl('/apps/assistant/api/v1/task/schedule') const params = { inputs, type: taskType, @@ -353,12 +353,12 @@ export function handleNotification(event) { */ async function showAssistantTaskResult(taskId) { const { default: axios } = await import(/* webpackChunkName: "axios-lazy" */'@nextcloud/axios') - const { generateUrl } = await import(/* webpackChunkName: "router-lazy" */'@nextcloud/router') + const { generateOcsUrl } = await import(/* webpackChunkName: "router-lazy" */'@nextcloud/router') const { showError } = await import(/* webpackChunkName: "dialogs-lazy" */'@nextcloud/dialogs') - const url = generateUrl('apps/assistant/task/{taskId}', { taskId }) + const url = generateOcsUrl('/apps/assistant/api/v1/task/{taskId}', { taskId }) axios.get(url).then(response => { - console.debug('showing results for task', response.data.task) - openAssistantTaskResult(response.data.task, true) + console.debug('showing results for task', response.data?.ocs?.data?.task) + openAssistantTaskResult(response.data?.ocs?.data?.task, true) }).catch(error => { console.error(error) showError(t('assistant', 'This task does not exist or has been cleaned up')) @@ -400,8 +400,8 @@ export async function openAssistantPlainTextResult(metaTask) { */ export async function openAssistantImageResult(metaTask) { // For now just open the image generation result on a new page: - const { generateUrl } = await import(/* webpackChunkName: "router-lazy" */'@nextcloud/router') - const url = generateUrl('apps/assistant/i/{genId}', { genId: metaTask.output }) + const { generateOcsUrl } = await import(/* webpackChunkName: "router-lazy" */'@nextcloud/router') + const url = generateOcsUrl('/apps/assistant/api/v1/i/{genId}', { genId: metaTask.output }) window.open(url, '_blank') } @@ -459,7 +459,7 @@ export async function openAssistantTaskResult(task, useMetaTasks = false) { scheduleTask(task.appId, task.identifier ?? '', data.selectedTaskTypeId, data.inputs) .then((response) => { view.showScheduleConfirmation = true - console.debug('scheduled task', response.data?.task) + console.debug('scheduled task', response.data?.ocs?.data?.task) }) .catch(error => { view.$destroy() @@ -485,8 +485,8 @@ export async function openAssistantTaskResult(task, useMetaTasks = false) { : runOrScheduleTask runOrScheduleFunction(task.appId, newTaskIdentifier, taskTypeId, inputs) .then((response) => { - // resolve(response.data?.task) - const task = response.data?.task + // resolve(response.data?.ocs?.data?.task) + const task = response.data?.ocs?.data?.task if (task.status === STATUS.successfull) { view.output = task?.output } else if (task.status === STATUS.scheduled) { diff --git a/src/components/AssistantFormInputs.vue b/src/components/AssistantFormInputs.vue index 1aa3b7b7..6a422038 100644 --- a/src/components/AssistantFormInputs.vue +++ b/src/components/AssistantFormInputs.vue @@ -100,7 +100,7 @@ import SpeechToTextInputForm from './SpeechToText/SpeechToTextInputForm.vue' import Text2ImageInputForm from './Text2Image/Text2ImageInputForm.vue' import { getFilePickerBuilder, showError } from '@nextcloud/dialogs' -import { generateUrl } from '@nextcloud/router' +import { generateOcsUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' const VALID_MIME_TYPES = [ @@ -191,25 +191,26 @@ export default { showError(t('assistant', 'No file selected')) return } - const url = generateUrl('apps/assistant/parse-file') + const url = generateOcsUrl('/apps/assistant/api/v1/parse-file') axios.post(url, { filePath, }).then((response) => { - if (response.data?.parsedText === undefined) { + const data = response.data?.ocs?.data + if (data?.parsedText === undefined) { showError(t('assistant', 'Unexpected response from text parser')) return } switch (target) { case 'sourceMaterial': - this.sourceMaterial = response.data.parsedText + this.sourceMaterial = data.parsedText this.onUpdateCopywriter() break case 'writingStyle': - this.writingStyle = response.data.parsedText + this.writingStyle = data.parsedText this.onUpdateCopywriter() break default: - this.prompt = response.data.parsedText + this.prompt = data.parsedText this.onUpdateMainInput() } }).catch((error) => { diff --git a/src/components/AssistantTextProcessingForm.vue b/src/components/AssistantTextProcessingForm.vue index 1d70c23c..4241521a 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -59,7 +59,8 @@