From 6451991d9916c3dc47f21c733df402626e14768f Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 24 Apr 2025 13:47:31 +0200 Subject: [PATCH 1/5] start adding reference widget for task output download links Signed-off-by: Julien Veyssier --- lib/AppInfo/Application.php | 4 + .../TaskOutputFileReferenceListener.php | 31 ++++ .../TaskOutputFileReferenceProvider.php | 137 ++++++++++++++++++ src/taskOutputFileReference.js | 19 +++ src/views/TaskOutputFileReferenceWidget.vue | 52 +++++++ vite.config.ts | 1 + 6 files changed, 244 insertions(+) create mode 100644 lib/Listener/TaskOutputFileReferenceListener.php create mode 100644 lib/Reference/TaskOutputFileReferenceProvider.php create mode 100644 src/taskOutputFileReference.js create mode 100644 src/views/TaskOutputFileReferenceWidget.vue 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/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/src/taskOutputFileReference.js b/src/taskOutputFileReference.js new file mode 100644 index 00000000..e31bc33a --- /dev/null +++ b/src/taskOutputFileReference.js @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { registerWidget } from '@nextcloud/vue/dist/Components/NcRichText.js' + +registerWidget('assistant_task-output-file', async (el, { richObjectType, richObject, accessible }) => { + const { default: Vue } = await import('vue') + Vue.mixin({ methods: { t, n } }) + const { default: TaskOutputFileReferenceWidget } = await import('./views/TaskOutputFileReferenceWidget.vue') + const Widget = Vue.extend(TaskOutputFileReferenceWidget) + new Widget({ + propsData: { + richObjectType, + richObject, + accessible, + }, + }).$mount(el) +}, () => {}, { hasInteractiveView: false }) diff --git a/src/views/TaskOutputFileReferenceWidget.vue b/src/views/TaskOutputFileReferenceWidget.vue new file mode 100644 index 00000000..d6126dde --- /dev/null +++ b/src/views/TaskOutputFileReferenceWidget.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/vite.config.ts b/vite.config.ts index 89c94800..d5fc532b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,7 @@ export default createAppConfig({ imageGenerationReference: 'src/imageGenerationReference.js', textGenerationReference: 'src/textGenerationReference.js', speechToTextReference: 'src/speechToTextReference.js', + taskOutputFileReference: 'src/taskOutputFileReference.js', assistantPage: 'src/assistantPage.js', }, { config: { From 4f7e40f4d9edcd51632020f427f74e2bb1acebf6 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 24 Apr 2025 17:06:51 +0200 Subject: [PATCH 2/5] use MediaField to render the output file in the reference widget, add a save button in MediaField Signed-off-by: Julien Veyssier --- appinfo/routes.php | 1 + lib/Controller/AssistantApiController.php | 27 +++++++++- lib/Service/AssistantService.php | 39 +++++++++++--- src/components/fields/MediaField.vue | 59 ++++++++++++++++----- src/views/TaskOutputFileReferenceWidget.vue | 23 ++++++-- 5 files changed, 123 insertions(+), 26 deletions(-) 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/Controller/AssistantApiController.php b/lib/Controller/AssistantApiController.php index d1235f46..1dc6d48e 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->debug('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/Service/AssistantService.php b/lib/Service/AssistantService.php index d23854a1..fa144352 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' => $fileCopy->getInternalPath(), + ]; } /** diff --git a/src/components/fields/MediaField.vue b/src/components/fields/MediaField.vue index e3d75ea4..b856054e 100644 --- a/src/components/fields/MediaField.vue +++ b/src/components/fields/MediaField.vue @@ -49,6 +49,13 @@ + + + @@ -76,6 +83,7 @@ import CloseIcon from 'vue-material-design-icons/Close.vue' import DownloadIcon from 'vue-material-design-icons/Download.vue' import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue' +import ContentSaveIcon from 'vue-material-design-icons/ContentSave.vue' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' @@ -107,6 +115,7 @@ export default { DownloadIcon, ShareVariantIcon, CloseIcon, + ContentSaveIcon, NcButton, }, @@ -220,21 +229,43 @@ export default { }) }, onShare() { - if (this.value !== null) { - const url = generateOcsUrl('/apps/assistant/api/v1/task/{taskId}/file/{fileId}/share', { - taskId: this.providedCurrentTaskId(), - fileId: this.value, - }) - axios.post(url).then(response => { - const shareToken = response.data.ocs.data.shareToken - const shareUrl = window.location.protocol + '//' + window.location.host + generateUrl('/s/{shareToken}', { shareToken }) - console.debug('[assistant] generated share link', shareUrl) - const message = t('assistant', 'Output file share link copied to clipboard') - this.copyString(shareUrl, message) - }).catch(error => { - console.error(error) - }) + if (this.value === null) { + return + } + + const url = generateOcsUrl('/apps/assistant/api/v1/task/{taskId}/file/{fileId}/share', { + taskId: this.providedCurrentTaskId(), + fileId: this.value, + }) + axios.post(url).then(response => { + const shareToken = response.data.ocs.data.shareToken + const shareUrl = window.location.protocol + '//' + window.location.host + generateUrl('/s/{shareToken}', { shareToken }) + console.debug('[assistant] generated share link', shareUrl) + const message = t('assistant', 'Output file share link copied to clipboard') + this.copyString(shareUrl, message) + }).catch(error => { + console.error(error) + }) + }, + onSave() { + if (this.value === null) { + return } + + const url = generateOcsUrl('/apps/assistant/api/v1/task/{taskId}/file/{fileId}/save', { + taskId: this.providedCurrentTaskId(), + fileId: this.value, + }) + axios.post(url).then(response => { + const savedPath = response.data.ocs.data.path + const savedFileId = response.data.ocs.data.fileId + console.debug('[assistant] save output file', savedPath) + const directUrl = window.location.protocol + '//' + window.location.host + generateUrl('/f/{savedFileId}', { savedFileId }) + const message = t('assistant', 'This output file has been saved in {path}', { path: savedPath }) + '\n' + directUrl + this.copyString(directUrl, message) + }).catch(error => { + console.error(error) + }) }, async copyString(content, message) { try { diff --git a/src/views/TaskOutputFileReferenceWidget.vue b/src/views/TaskOutputFileReferenceWidget.vue index d6126dde..207e2dbc 100644 --- a/src/views/TaskOutputFileReferenceWidget.vue +++ b/src/views/TaskOutputFileReferenceWidget.vue @@ -1,17 +1,29 @@ From 16d58a37c0a3f211b0346ec5f21bebd0c6c8a414 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 28 Apr 2025 11:29:49 +0200 Subject: [PATCH 4/5] fix: change the container of the NcModal to make sure the viewer is then displayed on top Signed-off-by: Julien Veyssier --- src/components/AssistantTextProcessingModal.vue | 1 + src/components/fields/MediaField.vue | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/MediaField.vue b/src/components/fields/MediaField.vue index 1961f463..49479081 100644 --- a/src/components/fields/MediaField.vue +++ b/src/components/fields/MediaField.vue @@ -295,7 +295,7 @@ export default { return axios.post(url).then(response => { const savedPath = response.data.ocs.data.path console.debug('[assistant] view output file', savedPath) - // TODO find a way to make it work when the assistant is in a modal itself + // This works and shows the Viewer on top the assitant's NcModal because we give it container="#content" OCA.Viewer.open({ path: savedPath }) }).catch(error => { console.error(error) From 78001421843c1eca6ee4bbaa85cd519efc786ec8 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 28 Apr 2025 11:44:29 +0200 Subject: [PATCH 5/5] fix REUSE issue and gen openAPI specs Signed-off-by: Julien Veyssier --- lib/Controller/AssistantApiController.php | 2 +- openapi.json | 144 +++++++++++++++++++- src/views/TaskOutputFileReferenceWidget.vue | 4 + 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/lib/Controller/AssistantApiController.php b/lib/Controller/AssistantApiController.php index 1dc6d48e..7a53a662 100644 --- a/lib/Controller/AssistantApiController.php +++ b/lib/Controller/AssistantApiController.php @@ -272,7 +272,7 @@ public function saveOutputFile(int $ocpTaskId, int $fileId): DataResponse { $info = $this->assistantService->saveOutputFile($this->userId, $ocpTaskId, $fileId); return new DataResponse($info); } catch (\Exception $e) { - $this->logger->debug('Failed to save assistant output file', ['exception' => $e]); + $this->logger->error('Failed to save assistant output file', ['exception' => $e]); return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); } } 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/views/TaskOutputFileReferenceWidget.vue b/src/views/TaskOutputFileReferenceWidget.vue index 207e2dbc..a4a5de4e 100644 --- a/src/views/TaskOutputFileReferenceWidget.vue +++ b/src/views/TaskOutputFileReferenceWidget.vue @@ -1,3 +1,7 @@ +