f7cloud_client/apps/assistant/lib/Controller/AssistantApiController.php
root 8b6a0139db f7cloud_client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:59:26 +00:00

451 lines
16 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2024 F7cloud GmbH and F7cloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Assistant\Controller;
use OCA\Assistant\ResponseDefinitions;
use OCA\Assistant\Service\AssistantService;
use OCA\Assistant\Service\TaskProcessingService;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\OCSController;
use OCP\DB\Exception;
use OCP\Files\File;
use OCP\Files\GenericFileException;
use OCP\Files\NotPermittedException;
use OCP\IL10N;
use OCP\IRequest;
use OCP\Lock\LockedException;
use OCP\TaskProcessing\Task;
use OCP\TaskProcessing\TaskTypes\AudioToText;
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* @psalm-import-type AssistantTaskProcessingTaskType from ResponseDefinitions
* @psalm-import-type AssistantTaskProcessingTask from ResponseDefinitions
*/
class AssistantApiController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private IL10N $l10n,
private AssistantService $assistantService,
private TaskProcessingService $taskProcessingService,
private LoggerInterface $logger,
private ?string $userId,
) {
parent::__construct($appName, $request);
}
/**
* Get the notification request for a task when it has finished
*
* 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.
*
* @param int $ocpTaskId ID of the target task
* @return DataResponse<Http::STATUS_OK, array{id: int, ocp_task_id: int, timestamp: int}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
* @throws MultipleObjectsReturnedException
*
* 200: Task notification request retrieved successfully
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[AnonRateLimit(limit: 10, period: 60)]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assistant_api'])]
public function getNotifyWhenReady(int $ocpTaskId): DataResponse {
if ($this->userId === null) {
return new DataResponse(['error' => $this->l10n->t('Failed to notify when ready; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$notification = $this->assistantService->getNotifyWhenReady($ocpTaskId, $this->userId);
return new DataResponse($notification, Http::STATUS_OK);
}
/**
* Notify when the task has finished
*
* 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.
*
* @param int $ocpTaskId ID of the target task
* @return DataResponse<Http::STATUS_OK, '', array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
* @throws MultipleObjectsReturnedException
*
* 200: Ready notification enabled successfully
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[AnonRateLimit(limit: 10, period: 60)]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assistant_api'])]
public function notifyWhenReady(int $ocpTaskId): 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->assistantService->notifyWhenReady($ocpTaskId, $this->userId);
} catch (Exception $e) {
// Ignore
}
return new DataResponse('', Http::STATUS_OK);
}
/**
* Cancel an existing notification when a task has finished
*
* 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.
*
* @param int $ocpTaskId ID of the target task
* @return DataResponse<Http::STATUS_OK, '', array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
* @throws MultipleObjectsReturnedException
*
* 200: Ready notification deleted successfully
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[AnonRateLimit(limit: 10, period: 60)]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assistant_api'])]
public function cancelNotifyWhenReady(int $ocpTaskId): DataResponse {
if ($this->userId === null) {
return new DataResponse(['error' => $this->l10n->t('Failed to cancel notification; unknown user')], Http::STATUS_INTERNAL_SERVER_ERROR);
}
try {
$this->assistantService->cancelNotifyWhenReady($ocpTaskId, $this->userId);
} catch (Exception $e) {
// Ignore
}
return new DataResponse('', Http::STATUS_OK);
}
/**
* Get available task types
*
* Get all available task types that the assistant can handle.
*
* @return DataResponse<Http::STATUS_OK, array{types: list<AssistantTaskProcessingTaskType>}, array{}>
*
* 200: Available task types returned
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['task_management'])]
public function getAvailableTaskTypes(): DataResponse {
$taskTypes = $this->assistantService->getAvailableTaskTypes();
return new DataResponse(['types' => $taskTypes]);
}
/**
* Get user's tasks
*
* Get a list of assistant tasks for the current user.
*
* @param string|null $taskTypeId Task type id. If null, tasks of all task types will be retrieved
* @return DataResponse<Http::STATUS_OK, array{tasks: list<AssistantTaskProcessingTask>}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, '', array{}>
*
* 200: User tasks returned
* 404: No tasks found
*/
#[NoAdminRequired]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['task_management'])]
public function getUserTasks(?string $taskTypeId = null): DataResponse {
if ($this->userId !== null) {
try {
$tasks = $this->assistantService->getUserTasks($this->userId, $taskTypeId);
$serializedTasks = array_map(static function (Task $task) {
return $task->jsonSerialize();
}, $tasks);
return new DataResponse(['tasks' => $serializedTasks]);
} catch (Exception $e) {
return new DataResponse(['tasks' => []]);
}
}
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
/**
* Extract text from file
*
* Parse and extract text content of a file (if the file type is supported)
*
* @param string|null $filePath Path of the file to parse in the user's storage
* @param int|null $fileId Id of the file to parse in the user's storage
* @return DataResponse<Http::STATUS_OK, array{parsedText: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, string, array{}>
*
* 200: Text parsed from file successfully
* 400: Parsing text from file is not possible
*/
#[NoAdminRequired]
public function parseTextFromFile(?string $filePath = null, ?int $fileId = null): DataResponse {
if ($this->userId === null) {
return new DataResponse('Unknow user', Http::STATUS_BAD_REQUEST);
}
if ($fileId === null && $filePath === null) {
return new DataResponse('Invalid parameters', Http::STATUS_BAD_REQUEST);
}
try {
$text = $this->assistantService->parseTextFromFile($this->userId, $filePath, $fileId);
} catch (\Exception|\Throwable $e) {
return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST);
}
return new DataResponse([
'parsedText' => $text,
]);
}
/**
* Upload input file
*
* Upload an input file for a task that is being prepared
*
* @param string|null $filename The input file name
* @return DataResponse<Http::STATUS_OK, array{fileId: int, filePath: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, string, array{}>
*
* 200: The input file was uploaded
* 400: Impossible to upload an input file
*/
#[NoAdminRequired]
public function uploadInputFile(?string $filename = null): DataResponse {
$inputData = $this->request->getUploadedFile('data');
if ($inputData['error'] !== 0) {
return new DataResponse('Error in input file upload: ' . $inputData['error'], Http::STATUS_BAD_REQUEST);
}
if (empty($inputData)) {
return new DataResponse('Invalid input data received', Http::STATUS_BAD_REQUEST);
}
try {
$fileInfo = $this->assistantService->storeInputFile($this->userId, $inputData['tmp_name'], $filename);
return new DataResponse([
'fileId' => $fileInfo['fileId'],
'filePath' => $fileInfo['filePath'],
]);
} catch (\Exception $e) {
$this->logger->error('Failed to store input file for assistant task', ['exception' => $e]);
return new DataResponse('Failed to store the input file: ' . $e->getMessage(), Http::STATUS_BAD_REQUEST);
}
}
/**
* Get a file of the current user
*
* @param int $fileId The ID of the file that is requested
* @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{message: string}, array{}>
* @throws GenericFileException
* @throws NotPermittedException
* @throws LockedException
*
* 200: The file is returned
* 404: The file was not found
*/
#[NoAdminRequired]
#[NoCsrfRequired]
public function displayUserFile(int $fileId): DataDownloadResponse|DataResponse {
$file = $this->assistantService->getUserFile($this->userId, $fileId);
if ($file !== null) {
return new DataDownloadResponse($file->getContent(), $file->getName(), $file->getMimeType());
}
return new DataResponse(['message' => 'Not found'], Http::STATUS_NOT_FOUND);
}
/**
* Get user file info
*
* Get information about a file of the current user
*
* @param int $fileId The file ID for which the info is requested
* @return DataResponse<Http::STATUS_OK, array{name: string, path: string, owner: string, size: int}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: The file info is returned
* 404: The file was not found
*/
#[NoAdminRequired]
#[NoCsrfRequired]
public function getUserFileInfo(int $fileId): DataResponse {
$fileInfo = $this->assistantService->getUserFileInfo($this->userId, $fileId);
if ($fileInfo !== null) {
return new DataResponse($fileInfo);
}
return new DataResponse(['message' => 'Not found'], Http::STATUS_NOT_FOUND);
}
/**
* Share an output file
*
* 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<Http::STATUS_OK, array{shareToken: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
*
* 200: The file was saved and shared
* 404: The file was not found
*/
#[NoAdminRequired]
public function shareOutputFile(int $ocpTaskId, int $fileId): DataResponse {
try {
$shareToken = $this->assistantService->shareOutputFile($this->userId, $ocpTaskId, $fileId);
return new DataResponse(['shareToken' => $shareToken]);
} catch (\Exception $e) {
$this->logger->debug('Failed to share assistant output file', ['exception' => $e]);
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND);
}
}
/**
* 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<Http::STATUS_OK, array{shareToken: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
*
* 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
*
* Generate and get a preview of a task output file
*
* @param int $ocpTaskId The task ID
* @param int $fileId The task output file ID
* @param int|null $x Optional preview width in pixels
* @param int|null $y Optional preview height in pixels
* @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_NOT_FOUND, '', array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
*
* 200: The file preview has been generated and is returned
* 303: Fallback to the file type icon URL
* 404: The output file is not found
*/
#[NoAdminRequired]
#[NoCsrfRequired]
public function getOutputFilePreview(int $ocpTaskId, int $fileId, ?int $x = 100, ?int $y = 100): RedirectResponse|DataDownloadResponse|DataResponse {
try {
$preview = $this->assistantService->getOutputFilePreviewFile($this->userId, $ocpTaskId, $fileId, $x, $y);
if ($preview === null) {
$this->logger->error('No preview for user "' . $this->userId . '"');
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
if ($preview['type'] === 'file') {
/** @var File $file */
$file = $preview['file'];
$response = new DataDownloadResponse(
$file->getContent(),
$ocpTaskId . '-' . $fileId . '-preview',
$file->getMimeType()
);
$response->cacheFor(60 * 60 * 24, false, true);
return $response;
} elseif ($preview['type'] === 'icon') {
return new RedirectResponse($preview['icon']);
}
} catch (Exception|Throwable $e) {
$this->logger->error('getImage error', ['exception' => $e]);
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
/**
* Get task output file
*
* Get a real task output file
*
* @param int $ocpTaskId The task ID
* @param int $fileId The task output file ID
* @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_NOT_FOUND, '', array{}>
*
* 200: The file preview has been generated and is returned
* 404: The output file is not found
*/
#[NoAdminRequired]
#[NoCsrfRequired]
public function getOutputFile(int $ocpTaskId, int $fileId): DataDownloadResponse|DataResponse {
try {
$taskOutputFile = $this->assistantService->getTaskOutputFile($this->userId, $ocpTaskId, $fileId);
$realMime = mime_content_type($taskOutputFile->fopen('rb'));
$response = new DataDownloadResponse(
$taskOutputFile->getContent(),
$ocpTaskId . '-' . $fileId,
$realMime ?: 'application/octet-stream',
);
$response->cacheFor(60 * 60 * 24, false, true);
return $response;
} catch (Exception|Throwable $e) {
$this->logger->error('getOutputFile error', ['exception' => $e]);
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
}
/**
* Run a file action
*
* Launch a task to process a file and store the result in a new file in the same directory
*
* @param int $fileId The input file ID
* @param string $taskTypeId The task type of the operation to perform
* @return DataResponse<Http::STATUS_OK, array{version: float, tooltip: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: The task has been scheduled successfully
* 400: There was an issue while scheduling the task
*/
#[NoAdminRequired]
public function runFileAction(int $fileId, string $taskTypeId): DataResponse {
try {
$this->taskProcessingService->runFileAction($this->userId, $fileId, $taskTypeId);
$message = $this->l10n->t('Assistant task submitted successfully');
if ($taskTypeId === AudioToText::ID) {
$message = $this->l10n->t('Transcription task submitted successfully');
} elseif ($taskTypeId === TextToTextSummary::ID) {
$message = $this->l10n->t('Summarization task submitted successfully');
} elseif (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToSpeech')) {
if ($taskTypeId === \OCP\TaskProcessing\TaskTypes\TextToSpeech::ID) {
$message = $this->l10n->t('Text-to-speech task submitted successfully');
}
}
return new DataResponse([
'version' => 0.1,
'tooltip' => $message,
]);
} catch (Exception|Throwable $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
}