Skip to content

Commit 6fc2627

Browse files
committed
feat(file-actions): add 3 file actions to summarize, tts and stt
Signed-off-by: Julien Veyssier <julien-nc@posteo.net>
1 parent ec6f230 commit 6fc2627

File tree

12 files changed

+589
-20
lines changed

12 files changed

+589
-20
lines changed

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
['name' => 'assistantApi#saveOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/file/{fileId}/save', 'verb' => 'POST', 'requirements' => $requirements],
3535
['name' => 'assistantApi#getOutputFilePreview', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/preview', 'verb' => 'GET', 'requirements' => $requirements],
3636
['name' => 'assistantApi#getOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/download', 'verb' => 'GET', 'requirements' => $requirements],
37+
['name' => 'assistantApi#runFileAction', 'url' => '/api/{apiVersion}/file-action/{fileId}/{taskTypeId}', 'verb' => 'POST', 'requirements' => $requirements],
3738

3839
['name' => 'chattyLLM#newSession', 'url' => '/chat/new_session', 'verb' => 'PUT'],
3940
['name' => 'chattyLLM#updateSessionTitle', 'url' => '/chat/update_session', 'verb' => 'PATCH'],

lib/AppInfo/Application.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
use OCA\Assistant\Listener\BeforeTemplateRenderedListener;
1212
use OCA\Assistant\Listener\ChattyLLMTaskListener;
1313
use OCA\Assistant\Listener\CSPListener;
14+
use OCA\Assistant\Listener\FileActionTaskListener;
1415
use OCA\Assistant\Listener\FreePrompt\FreePromptReferenceListener;
16+
use OCA\Assistant\Listener\LoadAdditionalScriptsListener;
1517
use OCA\Assistant\Listener\SpeechToText\SpeechToTextReferenceListener;
1618
use OCA\Assistant\Listener\TaskFailedListener;
1719
use OCA\Assistant\Listener\TaskOutputFileReferenceListener;
@@ -24,6 +26,7 @@
2426
use OCA\Assistant\Reference\Text2ImageReferenceProvider;
2527
use OCA\Assistant\TaskProcessing\AudioToAudioChatProvider;
2628
use OCA\Assistant\TaskProcessing\ContextAgentAudioInteractionProvider;
29+
use OCA\Files\Event\LoadAdditionalScriptsEvent;
2730
use OCP\AppFramework\App;
2831
use OCP\AppFramework\Bootstrap\IBootContext;
2932

@@ -64,10 +67,12 @@ public function register(IRegistrationContext $context): void {
6467
$context->registerEventListener(RenderReferenceEvent::class, TaskOutputFileReferenceListener::class);
6568

6669
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
70+
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalScriptsListener::class);
6771

6872
$context->registerEventListener(TaskSuccessfulEvent::class, TaskSuccessfulListener::class);
6973
$context->registerEventListener(TaskFailedEvent::class, TaskFailedListener::class);
7074
$context->registerEventListener(TaskSuccessfulEvent::class, ChattyLLMTaskListener::class);
75+
$context->registerEventListener(TaskSuccessfulEvent::class, FileActionTaskListener::class);
7176

7277
$context->registerNotifierService(Notifier::class);
7378

lib/Controller/AssistantApiController.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use OCA\Assistant\ResponseDefinitions;
1111
use OCA\Assistant\Service\AssistantService;
12+
use OCA\Assistant\Service\TaskProcessingService;
1213
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
1314
use OCP\AppFramework\Http;
1415
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
@@ -41,6 +42,7 @@ public function __construct(
4142
IRequest $request,
4243
private IL10N $l10n,
4344
private AssistantService $assistantService,
45+
private TaskProcessingService $taskProcessingService,
4446
private LoggerInterface $logger,
4547
private ?string $userId,
4648
) {
@@ -408,4 +410,26 @@ public function getOutputFile(int $ocpTaskId, int $fileId): DataDownloadResponse
408410
return new DataResponse('', Http::STATUS_NOT_FOUND);
409411
}
410412
}
413+
414+
/**
415+
* Run a file action
416+
*
417+
* Launch a task to process a file and store the result in a new file in the same directory
418+
*
419+
* @param int $fileId The input file ID
420+
* @param string $taskTypeId The task type of the operation to perform
421+
* @return DataResponse<Http::STATUS_OK, array{taskId: int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
422+
*
423+
* 200: The task has been scheduled successfully
424+
* 400: There was an issue while scheduling the task
425+
*/
426+
#[NoAdminRequired]
427+
public function runFileAction(int $fileId, string $taskTypeId): DataResponse {
428+
try {
429+
$taskId = $this->taskProcessingService->runFileAction($this->userId, $fileId, $taskTypeId);
430+
return new DataResponse(['taskId' => $taskId]);
431+
} catch (Exception|Throwable $e) {
432+
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
433+
}
434+
}
411435
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Assistant\Listener;
11+
12+
use OCA\Assistant\AppInfo\Application;
13+
use OCA\Assistant\Service\TaskProcessingService;
14+
use OCP\EventDispatcher\Event;
15+
use OCP\EventDispatcher\IEventListener;
16+
use OCP\Files\IRootFolder;
17+
use OCP\TaskProcessing\Events\TaskSuccessfulEvent;
18+
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
19+
use Psr\Log\LoggerInterface;
20+
21+
/**
22+
* @template-implements IEventListener<TaskSuccessfulEvent>
23+
*/
24+
class FileActionTaskListener implements IEventListener {
25+
26+
public function __construct(
27+
private TaskProcessingService $taskProcessingService,
28+
private IRootFolder $rootFolder,
29+
private LoggerInterface $logger,
30+
) {
31+
}
32+
33+
public function handle(Event $event): void {
34+
if (!($event instanceof TaskSuccessfulEvent)) {
35+
return;
36+
}
37+
38+
$task = $event->getTask();
39+
$customId = $task->getCustomId();
40+
$appId = $task->getAppId();
41+
$taskTypeId = $task->getTaskTypeId();
42+
43+
if ($customId === null || $appId !== (Application::APP_ID . ':file-action')) {
44+
return;
45+
}
46+
47+
if (!$this->taskProcessingService->isFileActionTaskTypeAuthorized($taskTypeId)) {
48+
return;
49+
}
50+
51+
if (preg_match('/^file-action:(\d+)$/', $customId, $matches)) {
52+
// we get the task output, write it in the output file (in the same dir as the source one)
53+
$sourceFileId = (int)$matches[1];
54+
$this->logger->debug('FileActionTaskListener', ['source file id' => $sourceFileId]);
55+
$userFolder = $this->rootFolder->getUserFolder($task->getUserId());
56+
$sourceFile = $userFolder->getFirstNodeById($sourceFileId);
57+
$sourceFileParent = $sourceFile->getParent();
58+
$this->logger->debug('FileActionTaskListener', ['source file PARENT id' => $sourceFileParent->getId()]);
59+
if (
60+
class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToSpeech')
61+
&& $taskTypeId === \OCP\TaskProcessing\TaskTypes\TextToSpeech::ID
62+
) {
63+
$speechFileId = $task->getOutput()['speech'];
64+
$speechFile = $this->taskProcessingService->getOutputFile($speechFileId);
65+
$mimeType = mime_content_type($speechFile->fopen('rb'));
66+
$mimeType = $mimeType ?: 'audio/wav';
67+
$mimes = new \Mimey\MimeTypes;
68+
$extension = $mimes->getExtension($mimeType);
69+
if ($extension === 'mpga') {
70+
$extension = 'mp3';
71+
}
72+
$targetFileName = $sourceFile->getName() . '_tts.' . $extension;
73+
$sourceFileParent->newFile($targetFileName, $speechFile->fopen('rb'));
74+
} else {
75+
$textResult = $task->getOutput()['output'];
76+
$suffix = $taskTypeId === TextToTextSummary::ID ? 'summarized' : 'transcribed';
77+
$targetFileName = $sourceFile->getName() . '_' . $suffix . '.txt';
78+
$sourceFileParent->newFile($targetFileName, $textResult);
79+
$this->logger->debug('FileActionTaskListener wrote file', ['target' => $targetFileName]);
80+
}
81+
// TODO maybe send a notification
82+
}
83+
}
84+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Assistant\Listener;
9+
10+
use OCA\Assistant\AppInfo\Application;
11+
use OCA\Files\Event\LoadAdditionalScriptsEvent;
12+
use OCP\AppFramework\Services\IInitialState;
13+
use OCP\EventDispatcher\Event;
14+
use OCP\EventDispatcher\IEventListener;
15+
use OCP\TaskProcessing\IManager as ITaskProcessingManager;
16+
use OCP\TaskProcessing\TaskTypes\AudioToText;
17+
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
18+
use OCP\Util;
19+
20+
/**
21+
* @implements IEventListener<Event>
22+
*/
23+
class LoadAdditionalScriptsListener implements IEventListener {
24+
25+
public function __construct(
26+
private IInitialState $initialStateService,
27+
private ITaskProcessingManager $taskProcessingManager,
28+
) {
29+
}
30+
31+
public function handle(Event $event): void {
32+
if (!$event instanceof LoadAdditionalScriptsEvent) {
33+
return;
34+
}
35+
36+
$availableTaskTypes = $this->taskProcessingManager->getAvailableTaskTypes();
37+
$summarizeAvailable = array_key_exists(TextToTextSummary::ID, $availableTaskTypes);
38+
$sttAvailable = array_key_exists(AudioToText::ID, $availableTaskTypes);
39+
$ttsAvailable = class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToSpeech')
40+
&& array_key_exists(\OCP\TaskProcessing\TaskTypes\TextToSpeech::ID, $availableTaskTypes);
41+
42+
$this->initialStateService->provideInitialState('stt-available', $sttAvailable);
43+
$this->initialStateService->provideInitialState('tts-available', $ttsAvailable);
44+
$this->initialStateService->provideInitialState('summarize-available', $summarizeAvailable);
45+
46+
Util::addInitScript(Application::APP_ID, Application::APP_ID . '-fileActions');
47+
}
48+
}

lib/Service/TaskProcessingService.php

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
namespace OCA\Assistant\Service;
99

10+
use OC\User\NoUserException;
11+
use OCA\Assistant\AppInfo\Application;
1012
use OCP\Files\File;
1113
use OCP\Files\GenericFileException;
1214
use OCP\Files\IRootFolder;
@@ -19,6 +21,8 @@
1921
use OCP\TaskProcessing\Exception\ValidationException;
2022
use OCP\TaskProcessing\IManager;
2123
use OCP\TaskProcessing\Task;
24+
use OCP\TaskProcessing\TaskTypes\AudioToText;
25+
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
2226
use RuntimeException;
2327

2428
class TaskProcessingService {
@@ -48,13 +52,10 @@ public function runTaskProcessingTask(Task $task): array {
4852

4953
/**
5054
* @param int $fileId
51-
* @return string
55+
* @return File
5256
* @throws NotFoundException
53-
* @throws GenericFileException
54-
* @throws NotPermittedException
55-
* @throws LockedException
5657
*/
57-
public function getOutputFileContent(int $fileId): string {
58+
public function getOutputFile(int $fileId): File {
5859
$node = $this->rootFolder->getFirstNodeById($fileId);
5960
if ($node === null) {
6061
$node = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/');
@@ -64,6 +65,63 @@ public function getOutputFileContent(int $fileId): string {
6465
} elseif (!$node instanceof File) {
6566
throw new NotFoundException('Node is not a file');
6667
}
67-
return $node->getContent();
68+
return $node;
69+
}
70+
71+
public function getOutputFileContent(int $fileId): string {
72+
$file = $this->getOutputFile($fileId);
73+
return $file->getContent();
74+
}
75+
76+
public function isFileActionTaskTypeAuthorized(string $taskTypeId): bool {
77+
$authorizedTaskTypes = [AudioToText::ID, TextToTextSummary::ID];
78+
if (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToSpeech')) {
79+
$authorizedTaskTypes[] = \OCP\TaskProcessing\TaskTypes\TextToSpeech::ID;
80+
}
81+
return in_array($taskTypeId, $authorizedTaskTypes, true);
82+
}
83+
84+
/**
85+
* Execute a file action
86+
*
87+
* @param string $userId
88+
* @param int $fileId
89+
* @param string $taskTypeId
90+
* @return int The scheduled task ID
91+
* @throws Exception
92+
* @throws GenericFileException
93+
* @throws LockedException
94+
* @throws NotFoundException
95+
* @throws NotPermittedException
96+
* @throws PreConditionNotMetException
97+
* @throws UnauthorizedException
98+
* @throws ValidationException
99+
* @throws NoUserException
100+
*/
101+
public function runFileAction(string $userId, int $fileId, string $taskTypeId): int {
102+
if (!$this->isFileActionTaskTypeAuthorized($taskTypeId)) {
103+
throw new PreConditionNotMetException();
104+
}
105+
$userFolder = $this->rootFolder->getUserFolder($userId);
106+
$file = $userFolder->getFirstNodeById($fileId);
107+
if (!$file instanceof File) {
108+
throw new NotFoundException('File is not a file');
109+
}
110+
$input = $taskTypeId === AudioToText::ID
111+
? ['input' => $fileId]
112+
: ['input' => $file->getContent()];
113+
$task = new Task(
114+
$taskTypeId,
115+
$input,
116+
Application::APP_ID . ':file-action',
117+
$userId,
118+
'file-action:' . $fileId,
119+
);
120+
$this->taskProcessingManager->scheduleTask($task);
121+
$taskId = $task->getId();
122+
if ($taskId === null) {
123+
throw new Exception('The task could not be scheduled');
124+
}
125+
return $taskId;
68126
}
69127
}

0 commit comments

Comments
 (0)