diff --git a/appinfo/routes.php b/appinfo/routes.php index 3dc4df1d0..e49b7d1d4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -34,6 +34,7 @@ ['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#getOutputFileInfo', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/info', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'assistantApi#getOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/download', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'assistantApi#runFileAction', 'url' => '/api/{apiVersion}/file-action/{fileId}/{taskTypeId}', 'verb' => 'POST', 'requirements' => $requirements], ['name' => 'assistantApi#getAssistantFolderPath', 'url' => '/api/{apiVersion}/assistant-folder-path', 'verb' => 'GET', 'requirements' => $requirements], diff --git a/lib/Controller/AssistantApiController.php b/lib/Controller/AssistantApiController.php index d9953d3dd..72f8119b2 100644 --- a/lib/Controller/AssistantApiController.php +++ b/lib/Controller/AssistantApiController.php @@ -27,6 +27,7 @@ use OCP\IL10N; use OCP\IRequest; use OCP\Lock\LockedException; +use OCP\TaskProcessing\Exception\NotFoundException as TaskProcessingNotFoundException; use OCP\TaskProcessing\Task; use OCP\TaskProcessing\TaskTypes\AudioToText; use OCP\TaskProcessing\TaskTypes\TextToTextSummary; @@ -421,6 +422,43 @@ public function getOutputFile(int $ocpTaskId, int $fileId): DataDownloadResponse } } + /** + * Get task output file info + * + * Get information about a task output file + * + * @param int $ocpTaskId The task ID + * @param int $fileId The task output file ID + * @return DataResponse|DataResponse|DataResponse + * + * 200: The file info is returned + * 404: The output file is not found + * 500: The output file info could not be retrieved + */ + #[NoAdminRequired] + #[NoCsrfRequired] + public function getOutputFileInfo(int $ocpTaskId, int $fileId): DataResponse { + try { + $fileInfo = $this->assistantService->getTaskOutputFileInfo($this->userId, $ocpTaskId, $fileId); + if ($fileInfo !== null) { + return new DataResponse($fileInfo); + } + return new DataResponse('', Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + // Unauthorized and not found both map to 404 so the response does + // not reveal whether another user's task exists. + if (in_array($e->getCode(), [Http::STATUS_UNAUTHORIZED, Http::STATUS_NOT_FOUND], true)) { + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + $this->logger->error('getOutputFileInfo error', ['exception' => $e]); + } catch (TaskProcessingNotFoundException $e) { + return new DataResponse('', Http::STATUS_NOT_FOUND); + } catch (Throwable $e) { + $this->logger->error('getOutputFileInfo error', ['exception' => $e]); + } + return new DataResponse('', Http::STATUS_INTERNAL_SERVER_ERROR); + } + /** * Run a file action * diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index 768b9f1ce..7cf65cecc 100644 --- a/lib/Service/AssistantService.php +++ b/lib/Service/AssistantService.php @@ -655,6 +655,23 @@ public function getTaskOutputFile(string $userId, int $ocpTaskId, int $fileId): return $node; } + /** + * @param string $userId + * @param int $ocpTaskId + * @param int $fileId + * @return array|null + * @throws Exception + * @throws NotFoundException + * @throws TaskProcessingException + */ + public function getTaskOutputFileInfo(string $userId, int $ocpTaskId, int $fileId): ?array { + $file = $this->getTaskOutputFile($userId, $ocpTaskId, $fileId); + return [ + 'name' => $file->getName(), + 'size' => $file->getSize(), + ]; + } + /** * @param string $userId * @param int $ocpTaskId diff --git a/openapi.json b/openapi.json index 1ed2389b1..d3afa4da3 100644 --- a/openapi.json +++ b/openapi.json @@ -2452,6 +2452,201 @@ } } }, + "/ocs/v2.php/apps/assistant/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/info": { + "get": { + "operationId": "assistant_api-get-output-file-info", + "summary": "Get task output file info", + "description": "Get information about a task output file", + "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 task output 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 info is returned", + "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": [ + "name", + "size" + ], + "properties": { + "name": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "The output file is 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": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "The output file info could not be retrieved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/assistant/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/download": { "get": { "operationId": "assistant_api-get-output-file", diff --git a/src/components/TaskListItem.vue b/src/components/TaskListItem.vue index 59091eb46..5db9b6f04 100644 --- a/src/components/TaskListItem.vue +++ b/src/components/TaskListItem.vue @@ -178,7 +178,8 @@ export default { subName() { if (this.task.status === TASK_STATUS_STRING.successful) { if (this.isText2Image) { - const nbGeneratedImages = this.task.output?.length ?? 0 + // text2image output is {images: [fileId, ...]}, not a bare array + const nbGeneratedImages = this.task.output?.images?.length ?? 0 return n('assistant', '{n} image generated', '{n} images generated', nbGeneratedImages, { n: nbGeneratedImages }) } return this.textOutputPreview diff --git a/src/components/fields/FileDisplay.vue b/src/components/fields/FileDisplay.vue index 31c5ee26c..d6cb35a5f 100644 --- a/src/components/fields/FileDisplay.vue +++ b/src/components/fields/FileDisplay.vue @@ -81,6 +81,8 @@ export default { console.debug('CURRENT TASK', this.myCurrentTaskId) if (!this.isOutput) { this.getFileInfo() + } else if (this.myCurrentTaskId !== null && this.myCurrentTaskId !== undefined) { + this.getOutputFileInfo() } }, @@ -92,6 +94,26 @@ export default { this.fileInfo = response.data.ocs.data }) }, + /** + * Fetch the output file info through the task-scoped endpoint. + * Retries once so a transient failure does not leave the chip + * unnamed for the component's lifetime. + */ + getOutputFileInfo(retry = true) { + const url = generateOcsUrl('apps/assistant/api/v1/task/{taskId}/output-file/{fileId}/info', { + taskId: this.myCurrentTaskId, + fileId: this.fileId, + }) + axios.get(url) + .then((response) => { + this.fileInfo = response.data.ocs.data + }) + .catch(() => { + if (retry) { + this.getOutputFileInfo(false) + } + }) + }, }, } diff --git a/src/components/fields/ImageDisplay.vue b/src/components/fields/ImageDisplay.vue index 7d51b5cbb..dead4d8e6 100644 --- a/src/components/fields/ImageDisplay.vue +++ b/src/components/fields/ImageDisplay.vue @@ -3,15 +3,22 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> @@ -98,5 +145,15 @@ export default { cursor: pointer !important; } } + .image-name { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + &.with-name { + flex-direction: column; + align-items: center; + } } diff --git a/src/components/fields/ListOfMediaField.vue b/src/components/fields/ListOfMediaField.vue index 860aa6a15..2dc384872 100644 --- a/src/components/fields/ListOfMediaField.vue +++ b/src/components/fields/ListOfMediaField.vue @@ -61,6 +61,7 @@ diff --git a/tests/unit/AppInfo/RoutesTest.php b/tests/unit/AppInfo/RoutesTest.php new file mode 100644 index 000000000..ba15a2c98 --- /dev/null +++ b/tests/unit/AppInfo/RoutesTest.php @@ -0,0 +1,61 @@ +taskProcessingManager = $this->createMock(ITaskProcessingManager::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + + $this->taskProcessingManager->method('getAvailableTaskTypes') + ->willReturn([ + 'test:tasktype' => [ + 'name' => 'Test task type', + 'description' => 'A test task type', + 'inputShape' => [], + 'optionalInputShape' => [], + 'outputShape' => [ + 'result' => new ShapeDescriptor('Result', 'The result file', EShapeType::File), + ], + 'optionalOutputShape' => [], + ], + ]); + + $this->service = new AssistantService( + $this->taskProcessingManager, + $this->createMock(TaskNotificationMapper::class), + $this->createMock(NotificationService::class), + $this->createMock(PreviewService::class), + $this->createMock(LoggerInterface::class), + $this->rootFolder, + $this->createMock(IL10N::class), + $this->createMock(ITempManager::class), + $this->createMock(IConfig::class), + $this->createMock(IAppConfig::class), + $this->createMock(IShareManager::class), + $this->createMock(SystemTagService::class), + ); + } + + private function getTask(string $userId): Task { + $task = new Task( + 'test:tasktype', + [], + 'assistant', + $userId, + ); + $task->setId(self::TASK_ID); + $task->setOutput(['result' => self::FILE_ID]); + return $task; + } + + private function getOutputFile(): File&MockObject { + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('result.txt'); + $file->method('getSize')->willReturn(1024); + return $file; + } + + /** + * An output file of the user's own task resolves to name and size. + */ + public function testGetTaskOutputFileInfo(): void { + $this->taskProcessingManager->method('getTask') + ->with(self::TASK_ID) + ->willReturn($this->getTask(self::USER_ID)); + $this->rootFolder->method('getFirstNodeById') + ->with(self::FILE_ID) + ->willReturn($this->getOutputFile()); + + $fileInfo = $this->service->getTaskOutputFileInfo(self::USER_ID, self::TASK_ID, self::FILE_ID); + + $this->assertSame([ + 'name' => 'result.txt', + 'size' => 1024, + ], $fileInfo); + } + + /** + * An output file stored in appdata instead of the user folder still + * resolves through the appdata path lookup. + */ + public function testGetTaskOutputFileInfoForAppdataFile(): void { + $this->taskProcessingManager->method('getTask') + ->with(self::TASK_ID) + ->willReturn($this->getTask(self::USER_ID)); + $this->rootFolder->method('getFirstNodeById') + ->with(self::FILE_ID) + ->willReturn(null); + $this->rootFolder->method('getAppDataDirectoryName') + ->willReturn('assistant'); + $this->rootFolder->method('getFirstNodeByIdInPath') + ->with(self::FILE_ID, '/assistant/') + ->willReturn($this->getOutputFile()); + + $fileInfo = $this->service->getTaskOutputFileInfo(self::USER_ID, self::TASK_ID, self::FILE_ID); + + $this->assertSame([ + 'name' => 'result.txt', + 'size' => 1024, + ], $fileInfo); + } + + /** + * A user cannot fetch output file info of another user's task. + */ + public function testGetTaskOutputFileInfoWrongUser(): void { + $this->taskProcessingManager->method('getTask') + ->with(self::TASK_ID) + ->willReturn($this->getTask(self::USER_ID)); + + $this->expectException(DBException::class); + $this->expectExceptionMessage('Unauthorized'); + + $this->service->getTaskOutputFileInfo('another_user', self::TASK_ID, self::FILE_ID); + } + + /** + * A file id that does not belong to the task is rejected as not found. + */ + public function testGetTaskOutputFileInfoUnknownFileId(): void { + $this->taskProcessingManager->method('getTask') + ->with(self::TASK_ID) + ->willReturn($this->getTask(self::USER_ID)); + + $this->expectException(DBException::class); + $this->expectExceptionMessage('Not found'); + + $this->service->getTaskOutputFileInfo(self::USER_ID, self::TASK_ID, 999999999); + } + + /** + * A node id that resolves to nothing at all is rejected as not a file. + */ + public function testGetTaskOutputFileInfoNotAFileNode(): void { + $this->taskProcessingManager->method('getTask') + ->with(self::TASK_ID) + ->willReturn($this->getTask(self::USER_ID)); + $this->rootFolder->method('getFirstNodeById') + ->with(self::FILE_ID) + ->willReturn(null); + $this->rootFolder->method('getAppDataDirectoryName') + ->willReturn('assistant'); + $this->rootFolder->method('getFirstNodeByIdInPath') + ->with(self::FILE_ID, '/assistant/') + ->willReturn(null); + + $this->expectException(TaskProcessingNotFoundException::class); + $this->expectExceptionMessage('Node is not a file'); + + $this->service->getTaskOutputFileInfo(self::USER_ID, self::TASK_ID, self::FILE_ID); + } + + /** + * A node id that resolves to a non-file node (for example a folder) + * through the root lookup is rejected exactly like the missing case, + * so the instance check cannot be dropped without a failing test. + */ + public function testGetTaskOutputFileInfoNotAFolderNode(): void { + $folder = $this->createMock(Folder::class); + $this->taskProcessingManager->method('getTask') + ->with(self::TASK_ID) + ->willReturn($this->getTask(self::USER_ID)); + $this->rootFolder->method('getFirstNodeById') + ->with(self::FILE_ID) + ->willReturn($folder); + + $this->expectException(TaskProcessingNotFoundException::class); + $this->expectExceptionMessage('Node is not a file'); + + $this->service->getTaskOutputFileInfo(self::USER_ID, self::TASK_ID, self::FILE_ID); + } +}