diff --git a/appinfo/routes.php b/appinfo/routes.php index 6f8ca95d..26880aff 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -29,6 +29,7 @@ ['name' => 'assistantApi#displayUserFile', 'url' => '/api/{apiVersion}/file/{fileId}/display', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'assistantApi#getUserFileInfo', 'url' => '/api/{apiVersion}/file/{fileId}/info', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'assistantApi#shareOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/file/{fileId}/share', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'assistantApi#saveOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/file/{fileId}/save', 'verb' => 'POST', 'requirements' => $requirements], ['name' => 'assistantApi#getOutputFilePreview', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/preview', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'assistantApi#getOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/download', 'verb' => 'GET', 'requirements' => $requirements], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index ac601e41..6a87bb90 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -14,11 +14,13 @@ use OCA\Assistant\Listener\FreePrompt\FreePromptReferenceListener; use OCA\Assistant\Listener\SpeechToText\SpeechToTextReferenceListener; use OCA\Assistant\Listener\TaskFailedListener; +use OCA\Assistant\Listener\TaskOutputFileReferenceListener; use OCA\Assistant\Listener\TaskSuccessfulListener; use OCA\Assistant\Listener\Text2Image\Text2ImageReferenceListener; use OCA\Assistant\Notification\Notifier; use OCA\Assistant\Reference\FreePromptReferenceProvider; use OCA\Assistant\Reference\SpeechToTextReferenceProvider; +use OCA\Assistant\Reference\TaskOutputFileReferenceProvider; use OCA\Assistant\Reference\Text2ImageReferenceProvider; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -52,10 +54,12 @@ public function register(IRegistrationContext $context): void { $context->registerReferenceProvider(Text2ImageReferenceProvider::class); $context->registerReferenceProvider(FreePromptReferenceProvider::class); $context->registerReferenceProvider(SpeechToTextReferenceProvider::class); + $context->registerReferenceProvider(TaskOutputFileReferenceProvider::class); $context->registerEventListener(RenderReferenceEvent::class, Text2ImageReferenceListener::class); $context->registerEventListener(RenderReferenceEvent::class, FreePromptReferenceListener::class); $context->registerEventListener(RenderReferenceEvent::class, SpeechToTextReferenceListener::class); + $context->registerEventListener(RenderReferenceEvent::class, TaskOutputFileReferenceListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); diff --git a/lib/Controller/AssistantApiController.php b/lib/Controller/AssistantApiController.php index d1235f46..7a53a662 100644 --- a/lib/Controller/AssistantApiController.php +++ b/lib/Controller/AssistantApiController.php @@ -234,13 +234,13 @@ public function getUserFileInfo(int $fileId): DataResponse { /** * Share an output file * - * Share a file that was produced by a task + * Save and share a file that was produced by a task * * @param int $ocpTaskId The task ID * @param int $fileId The file ID * @return DataResponse|DataResponse * - * 200: The file was shared + * 200: The file was saved and shared * 404: The file was not found */ #[NoAdminRequired] @@ -254,6 +254,29 @@ public function shareOutputFile(int $ocpTaskId, int $fileId): DataResponse { } } + /** + * Save an output file + * + * Save a file that was produced by a task + * + * @param int $ocpTaskId The task ID + * @param int $fileId The file ID + * @return DataResponse|DataResponse + * + * 200: The file was saved + * 404: The file was not found + */ + #[NoAdminRequired] + public function saveOutputFile(int $ocpTaskId, int $fileId): DataResponse { + try { + $info = $this->assistantService->saveOutputFile($this->userId, $ocpTaskId, $fileId); + return new DataResponse($info); + } catch (\Exception $e) { + $this->logger->error('Failed to save assistant output file', ['exception' => $e]); + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } + } + /** * Get task output file preview * diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php index 4e966e30..56c2db91 100644 --- a/lib/Listener/BeforeTemplateRenderedListener.php +++ b/lib/Listener/BeforeTemplateRenderedListener.php @@ -64,6 +64,9 @@ public function handle(Event $event): void { $indexingComplete = $this->appConfig->getValueInt('context_chat', 'last_indexed_time', 0) !== 0; $this->initialStateService->provideInitialState('contextChatIndexingComplete', $indexingComplete); } + if (class_exists(\OCA\Viewer\Event\LoadViewer::class)) { + $this->eventDispatcher->dispatchTyped(new \OCA\Viewer\Event\LoadViewer()); + } Util::addScript(Application::APP_ID, Application::APP_ID . '-main'); } } diff --git a/lib/Listener/TaskOutputFileReferenceListener.php b/lib/Listener/TaskOutputFileReferenceListener.php new file mode 100644 index 00000000..7b5ba39a --- /dev/null +++ b/lib/Listener/TaskOutputFileReferenceListener.php @@ -0,0 +1,31 @@ + + */ +class TaskOutputFileReferenceListener implements IEventListener { + public function __construct( + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof RenderReferenceEvent) { + return; + } + + Util::addScript(Application::APP_ID, Application::APP_ID . '-taskOutputFileReference'); + } +} diff --git a/lib/Reference/TaskOutputFileReferenceProvider.php b/lib/Reference/TaskOutputFileReferenceProvider.php new file mode 100644 index 00000000..a811f1a7 --- /dev/null +++ b/lib/Reference/TaskOutputFileReferenceProvider.php @@ -0,0 +1,137 @@ +getLinkInfo($referenceText) !== null; + } + + /** + * @inheritDoc + */ + public function resolveReference(string $referenceText): ?IReference { + if ($this->matchReference($referenceText)) { + $linkInfo = $this->getLinkInfo($referenceText); + if ($linkInfo !== null) { + $taskId = $linkInfo['taskId']; + $task = $this->taskProcessingManager->getTask($taskId); + if ($task->getUserId() === null || $task->getUserId() !== $this->userId) { + return null; + } + + $linkInfo['taskTypeId'] = $task->getTaskTypeId(); + $linkInfo['taskTypeName'] = $this->taskProcessingManager->getAvailableTaskTypes()[$task->getTaskTypeId()]['name'] ?? null; + $reference = new Reference($referenceText); + $reference->setRichObject( + self::RICH_OBJECT_TYPE, + $linkInfo, + ); + return $reference; + } + // fallback to opengraph + return $this->linkReferenceProvider->resolveReference($referenceText); + } + + return null; + } + + /** + * @param string $url + * @return array|null + */ + private function getLinkinfo(string $url): ?array { + // assistant download link + // https://nextcloud.local/ocs/v2.php/apps/assistant/api/v1/task/42/output-file/398/download + + $start = $this->urlGenerator->linkToOCSRouteAbsolute(Application::APP_ID . '.assistantApi.getOutputFile', [ + 'apiVersion' => 'v1', + 'ocpTaskId' => 123, + 'fileId' => 123, + ]); + $start = str_replace('/task/123/output-file/123/download', '/task/', $start); + if (str_starts_with($url, $start)) { + preg_match('/\/task\/(\d+)\/output-file\/(\d+)\/download$/i', $url, $matches); + if (count($matches) > 2) { + return [ + 'taskId' => (int)$matches[1], + 'fileId' => (int)$matches[2], + ]; + } + } + + // task processing download links + // https://nextcloud.local/ocs/v2.php/taskprocessing/tasks/42/file/398 + + $start = $this->urlGenerator->linkToOCSRouteAbsolute('core.taskProcessingApi.getFileContents', [ + 'taskId' => 123, + 'fileId' => 123, + ]); + $start = str_replace('/tasks/123/file/123', '/tasks/', $start); + if (str_starts_with($url, $start)) { + preg_match('/\/tasks\/(\d+)\/file\/(\d+)$/i', $url, $matches); + if (count($matches) > 2) { + return [ + 'taskId' => (int)$matches[1], + 'fileId' => (int)$matches[2], + ]; + } + } + + return null; + } + + /** + * We use the userId here because when connecting/disconnecting from the GitHub account, + * we want to invalidate all the user cache and this is only possible with the cache prefix + * @inheritDoc + */ + public function getCachePrefix(string $referenceId): string { + return $this->userId ?? ''; + } + + /** + * We don't use the userId here but rather a reference unique id + * @inheritDoc + */ + public function getCacheKey(string $referenceId): ?string { + return $referenceId; + } + + /** + * @param string $userId + * @return void + */ + public function invalidateUserCache(string $userId): void { + $this->referenceManager->invalidateCache($userId); + } +} diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index d23854a1..aad0b0f3 100644 --- a/lib/Service/AssistantService.php +++ b/lib/Service/AssistantService.php @@ -391,7 +391,7 @@ public function getTaskOutputFile(string $userId, int $ocpTaskId, int $fileId): * @param string $userId * @param int $ocpTaskId * @param int $fileId - * @return string + * @return File * @throws Exception * @throws InvalidPathException * @throws LockedException @@ -402,7 +402,7 @@ public function getTaskOutputFile(string $userId, int $ocpTaskId, int $fileId): * @throws TaskProcessingException * @throws \OCP\Files\NotFoundException */ - public function shareOutputFile(string $userId, int $ocpTaskId, int $fileId): string { + private function saveFile(string $userId, int $ocpTaskId, int $fileId): File { $taskOutputFile = $this->getTaskOutputFile($userId, $ocpTaskId, $fileId); $assistantDataFolder = $this->getAssistantDataFolder($userId); $targetFileName = $this->getTargetFileName($taskOutputFile); @@ -410,16 +410,35 @@ public function shareOutputFile(string $userId, int $ocpTaskId, int $fileId): st $existingTarget = $assistantDataFolder->get($targetFileName); if ($existingTarget instanceof File) { if ($existingTarget->getSize() === $taskOutputFile->getSize()) { - $fileCopy = $existingTarget; + return $existingTarget; } else { - $fileCopy = $assistantDataFolder->newFile($targetFileName, $taskOutputFile->fopen('rb')); + return $assistantDataFolder->newFile($targetFileName, $taskOutputFile->fopen('rb')); } } else { throw new Exception('Impossible to copy output file, a directory with this name already exists', Http::STATUS_UNAUTHORIZED); } } else { - $fileCopy = $assistantDataFolder->newFile($targetFileName, $taskOutputFile->fopen('rb')); + return $assistantDataFolder->newFile($targetFileName, $taskOutputFile->fopen('rb')); } + } + + /** + * @param string $userId + * @param int $ocpTaskId + * @param int $fileId + * @return string + * @throws Exception + * @throws InvalidPathException + * @throws LockedException + * @throws NoUserException + * @throws NotFoundException + * @throws NotPermittedException + * @throws PreConditionNotMetException + * @throws TaskProcessingException + * @throws \OCP\Files\NotFoundException + */ + public function shareOutputFile(string $userId, int $ocpTaskId, int $fileId): string { + $fileCopy = $this->saveFile($userId, $ocpTaskId, $fileId); $share = $this->shareManager->newShare(); $share->setNode($fileCopy); $share->setPermissions(Constants::PERMISSION_READ); @@ -427,9 +446,15 @@ public function shareOutputFile(string $userId, int $ocpTaskId, int $fileId): st $share->setSharedBy($userId); $share->setLabel('Assistant share'); $share = $this->shareManager->createShare($share); - $shareToken = $share->getToken(); + return $share->getToken(); + } - return $shareToken; + public function saveOutputFile(string $userId, int $ocpTaskId, int $fileId): array { + $fileCopy = $this->saveFile($userId, $ocpTaskId, $fileId); + return [ + 'fileId' => $fileCopy->getId(), + 'path' => preg_replace('/^files\//', '/', $fileCopy->getInternalPath()), + ]; } /** diff --git a/openapi.json b/openapi.json index 6336fe41..6b0d2626 100644 --- a/openapi.json +++ b/openapi.json @@ -1253,7 +1253,7 @@ "post": { "operationId": "assistant_api-share-output-file", "summary": "Share an output file", - "description": "Share a file that was produced by a task", + "description": "Save and share a file that was produced by a task", "tags": [ "assistant_api" ], @@ -1311,7 +1311,147 @@ ], "responses": { "200": { - "description": "The file was shared", + "description": "The file was saved and shared", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "shareToken" + ], + "properties": { + "shareToken": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "The file was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/assistant/api/{apiVersion}/task/{ocpTaskId}/file/{fileId}/save": { + "post": { + "operationId": "assistant_api-save-output-file", + "summary": "Save an output file", + "description": "Save a file that was produced by a task", + "tags": [ + "assistant_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "ocpTaskId", + "in": "path", + "description": "The task ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "fileId", + "in": "path", + "description": "The file ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "The file was saved", "content": { "application/json": { "schema": { diff --git a/src/components/AssistantTextProcessingModal.vue b/src/components/AssistantTextProcessingModal.vue index d4258a9b..c53d0314 100644 --- a/src/components/AssistantTextProcessingModal.vue +++ b/src/components/AssistantTextProcessingModal.vue @@ -7,6 +7,7 @@ :size="modalSize" :can-close="false" :name="t('assistant', 'Nextcloud Assistant')" + container="#content" dark class="assistant-modal" @close="onCancel"> diff --git a/src/components/fields/FileDisplay.vue b/src/components/fields/FileDisplay.vue index 19ef8bd6..508b95fc 100644 --- a/src/components/fields/FileDisplay.vue +++ b/src/components/fields/FileDisplay.vue @@ -5,7 +5,7 @@