Skip to content

Commit bb07161

Browse files
authored
Merge pull request #322 from nextcloud/feat/files_new_menu/generate-image
feat: files "new file" menu generate image action
2 parents cc6e078 + 534ca3a commit bb07161

12 files changed

+439
-6
lines changed

lib/AppInfo/Application.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use OCA\Assistant\Listener\FileActionTaskSuccessfulListener;
1616
use OCA\Assistant\Listener\FreePrompt\FreePromptReferenceListener;
1717
use OCA\Assistant\Listener\LoadAdditionalScriptsListener;
18+
use OCA\Assistant\Listener\NewFileMenuTaskFailedListener;
19+
use OCA\Assistant\Listener\NewFileMenuTaskSuccessfulListener;
1820
use OCA\Assistant\Listener\SpeechToText\SpeechToTextReferenceListener;
1921
use OCA\Assistant\Listener\TaskFailedListener;
2022
use OCA\Assistant\Listener\TaskOutputFileReferenceListener;
@@ -74,7 +76,9 @@ public function register(IRegistrationContext $context): void {
7476
$context->registerEventListener(TaskFailedEvent::class, TaskFailedListener::class);
7577
$context->registerEventListener(TaskSuccessfulEvent::class, ChattyLLMTaskListener::class);
7678
$context->registerEventListener(TaskSuccessfulEvent::class, FileActionTaskSuccessfulListener::class);
79+
$context->registerEventListener(TaskSuccessfulEvent::class, NewFileMenuTaskSuccessfulListener::class);
7780
$context->registerEventListener(TaskFailedEvent::class, FileActionTaskFailedListener::class);
81+
$context->registerEventListener(TaskFailedEvent::class, NewFileMenuTaskFailedListener::class);
7882

7983
$context->registerNotifierService(Notifier::class);
8084

lib/Listener/LoadAdditionalScriptsListener.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
use OCP\AppFramework\Services\IInitialState;
1313
use OCP\EventDispatcher\Event;
1414
use OCP\EventDispatcher\IEventListener;
15+
use OCP\IAppConfig;
1516
use OCP\TaskProcessing\IManager as ITaskProcessingManager;
1617
use OCP\TaskProcessing\TaskTypes\AudioToText;
18+
use OCP\TaskProcessing\TaskTypes\TextToImage;
1719
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
1820
use OCP\Util;
1921

@@ -23,6 +25,7 @@
2325
class LoadAdditionalScriptsListener implements IEventListener {
2426

2527
public function __construct(
28+
private IAppConfig $appConfig,
2629
private IInitialState $initialStateService,
2730
private ITaskProcessingManager $taskProcessingManager,
2831
) {
@@ -34,15 +37,25 @@ public function handle(Event $event): void {
3437
}
3538

3639
$availableTaskTypes = $this->taskProcessingManager->getAvailableTaskTypes();
40+
41+
// file actions
3742
$summarizeAvailable = array_key_exists(TextToTextSummary::ID, $availableTaskTypes);
3843
$sttAvailable = array_key_exists(AudioToText::ID, $availableTaskTypes);
3944
$ttsAvailable = class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToSpeech')
4045
&& array_key_exists(\OCP\TaskProcessing\TaskTypes\TextToSpeech::ID, $availableTaskTypes);
41-
4246
$this->initialStateService->provideInitialState('stt-available', $sttAvailable);
4347
$this->initialStateService->provideInitialState('tts-available', $ttsAvailable);
4448
$this->initialStateService->provideInitialState('summarize-available', $summarizeAvailable);
45-
4649
Util::addInitScript(Application::APP_ID, Application::APP_ID . '-fileActions');
50+
51+
// New file menu to generate images
52+
$isNewFileMenuEnabled = $this->appConfig->getValueInt(Application::APP_ID, 'new_image_file_menu_plugin', 1) === 1;
53+
if ($isNewFileMenuEnabled) {
54+
$hasText2Image = array_key_exists(TextToImage::ID, $availableTaskTypes);
55+
$this->initialStateService->provideInitialState('new-file-generate-image', [
56+
'hasText2Image' => $hasText2Image,
57+
]);
58+
Util::addScript(Application::APP_ID, Application::APP_ID . '-filesNewMenu');
59+
}
4760
}
4861
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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\Service\NotificationService;
11+
use OCP\EventDispatcher\Event;
12+
use OCP\EventDispatcher\IEventListener;
13+
use OCP\Files\IRootFolder;
14+
use OCP\TaskProcessing\Events\TaskFailedEvent;
15+
16+
/**
17+
* @template-implements IEventListener<Event>
18+
*/
19+
class NewFileMenuTaskFailedListener implements IEventListener {
20+
21+
public function __construct(
22+
private NotificationService $notificationService,
23+
private IRootFolder $rootFolder,
24+
) {
25+
}
26+
27+
public function handle(Event $event): void {
28+
if (!$event instanceof TaskFailedEvent) {
29+
return;
30+
}
31+
32+
$task = $event->getTask();
33+
if ($task->getUserId() === null) {
34+
return;
35+
}
36+
37+
$customIdPattern = '/^new-image-file:(\d+)$/';
38+
$isNewImageFileAction = preg_match($customIdPattern, $task->getCustomId(), $matches) === 1;
39+
40+
// For tasks with customId "new-image-file:<directoryIdNumber>" we always send a notification
41+
if (!$isNewImageFileAction) {
42+
return;
43+
}
44+
45+
$directoryId = (int)$matches[1];
46+
$userFolder = $this->rootFolder->getUserFolder($task->getUserId());
47+
$directory = $userFolder->getFirstNodeById($directoryId);
48+
$this->notificationService->sendNewImageFileNotification(
49+
$task->getUserId(), $task->getId(),
50+
$directoryId, $directory->getName(), $userFolder->getRelativePath($directory->getPath()),
51+
);
52+
}
53+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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\Service\AssistantService;
11+
use OCA\Assistant\Service\NotificationService;
12+
use OCP\EventDispatcher\Event;
13+
use OCP\EventDispatcher\IEventListener;
14+
use OCP\Files\IRootFolder;
15+
use OCP\TaskProcessing\Events\TaskSuccessfulEvent;
16+
use Psr\Log\LoggerInterface;
17+
18+
/**
19+
* @template-implements IEventListener<Event>
20+
*/
21+
class NewFileMenuTaskSuccessfulListener implements IEventListener {
22+
23+
public function __construct(
24+
private NotificationService $notificationService,
25+
private AssistantService $assistantService,
26+
private LoggerInterface $logger,
27+
private IRootFolder $rootFolder,
28+
) {
29+
}
30+
31+
public function handle(Event $event): void {
32+
if (!$event instanceof TaskSuccessfulEvent) {
33+
return;
34+
}
35+
36+
$task = $event->getTask();
37+
if ($task->getUserId() === null) {
38+
return;
39+
}
40+
41+
$customIdPattern = '/^new-image-file:(\d+)$/';
42+
$isNewImageFileAction = preg_match($customIdPattern, $task->getCustomId(), $matches) === 1;
43+
44+
// For tasks with customId "new-image-file:<directoryIdNumber>" we always send a notification
45+
if (!$isNewImageFileAction) {
46+
return;
47+
}
48+
49+
$directoryId = (int)$matches[1];
50+
$fileId = (int)$task->getOutput()['images'][0];
51+
try {
52+
$targetFile = $this->assistantService->saveNewFileMenuActionFile($task->getUserId(), $task->getId(), $fileId, $directoryId);
53+
$userFolder = $this->rootFolder->getUserFolder($task->getUserId());
54+
$directory = $targetFile->getParent();
55+
$this->notificationService->sendNewImageFileNotification(
56+
$task->getUserId(), $task->getId(),
57+
$directoryId, $directory->getName(), $userFolder->getRelativePath($directory->getPath()),
58+
$targetFile->getId(), $targetFile->getName(), $userFolder->getRelativePath($targetFile->getPath()),
59+
);
60+
} catch (\Exception $e) {
61+
$this->logger->error('TaskSuccessfulListener: Failed to save new file menu action file.', [
62+
'task' => $task->jsonSerialize(),
63+
'exception' => $e,
64+
]);
65+
}
66+
67+
}
68+
}

lib/Notification/Notifier.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,70 @@ public function prepare(INotification $notification, string $languageCode): INot
274274

275275
return $notification;
276276

277+
case 'new_image_file_success':
278+
$subject = $l->t('New image file has been generated');
279+
280+
$targetDirLink = $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $params['target_directory_id']]);
281+
$targetFileLink = $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $params['target_file_id']]);
282+
$taskLink = $params['target'];
283+
$iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'));
284+
285+
$message = $l->t('{targetFile} has been generated in {targetDirectory}');
286+
287+
$notification
288+
->setParsedSubject($subject)
289+
->setRichMessage($message, [
290+
'targetDirectory' => [
291+
'type' => 'file',
292+
'id' => (string)$params['target_directory_id'],
293+
'name' => $params['target_directory_name'],
294+
'path' => $params['target_directory_path'],
295+
'link' => $targetDirLink,
296+
],
297+
'targetFile' => [
298+
'type' => 'file',
299+
'id' => (string)$params['target_file_id'],
300+
'name' => $params['target_file_name'],
301+
'path' => $params['target_file_path'],
302+
'link' => $targetFileLink,
303+
],
304+
])
305+
->setLink($taskLink)
306+
->setIcon($iconUrl);
307+
308+
$actionLabel = $l->t('View results');
309+
$action = $notification->createAction();
310+
$action->setLabel($actionLabel)
311+
->setParsedLabel($actionLabel)
312+
->setLink($taskLink, IAction::TYPE_WEB)
313+
->setPrimary(true);
314+
315+
$notification->addParsedAction($action);
316+
317+
return $notification;
318+
319+
case 'new_image_file_failure':
320+
$subject = $l->t('Image file generation has failed');
321+
322+
$iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'));
323+
324+
$message = $l->t('Generation of a new image file in {targetDirectory} has failed');
325+
326+
$notification
327+
->setParsedSubject($subject)
328+
->setRichMessage($message, [
329+
'targetDirectory' => [
330+
'type' => 'file',
331+
'id' => (string)$params['target_directory_id'],
332+
'name' => $params['target_directory_name'],
333+
'path' => $params['target_directory_path'],
334+
'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $params['target_directory_id']]),
335+
],
336+
])
337+
->setIcon($iconUrl);
338+
339+
return $notification;
340+
277341
default:
278342
// Unknown subject => Unknown notification => throw
279343
throw new InvalidArgumentException();

lib/Service/AssistantService.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,25 @@ private function saveFile(string $userId, int $ocpTaskId, int $fileId): File {
533533
}
534534
}
535535

536+
/**
537+
* @throws TaskProcessingException
538+
* @throws NotPermittedException
539+
* @throws NotFoundException
540+
* @throws Exception
541+
* @throws LockedException
542+
* @throws NoUserException
543+
*/
544+
public function saveNewFileMenuActionFile(string $userId, int $ocpTaskId, int $fileId, int $targetDirectoryId): File {
545+
$taskOutputFile = $this->getTaskOutputFile($userId, $ocpTaskId, $fileId);
546+
$userFolder = $this->rootFolder->getUserFolder($userId);
547+
$targetDirectory = $userFolder->getFirstNodeById($targetDirectoryId);
548+
if (!$targetDirectory instanceof Folder) {
549+
throw new NotFoundException('Target directory not found: ' . $targetDirectoryId);
550+
}
551+
$targetFileName = $this->getTargetFileName($taskOutputFile);
552+
return $targetDirectory->newFile($targetFileName, $taskOutputFile->fopen('rb'));
553+
}
554+
536555
/**
537556
* @param string $userId
538557
* @param int $ocpTaskId

lib/Service/NotificationService.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,37 @@ public function sendFileActionNotification(
100100

101101
$manager->notify($notification);
102102
}
103+
104+
public function sendNewImageFileNotification(
105+
string $userId, int $taskId,
106+
?int $targetDirectoryId = null, ?string $targetDirectoryName = null, ?string $targetDirectoryPath = null,
107+
?int $targetFileId = null, ?string $targetFileName = null, ?string $targetFilePath = null,
108+
): void {
109+
$manager = $this->notificationManager;
110+
$notification = $manager->createNotification();
111+
112+
$params = [
113+
'target_directory_id' => $targetDirectoryId,
114+
'target_directory_name' => $targetDirectoryName,
115+
'target_directory_path' => $targetDirectoryPath,
116+
'target_file_id' => $targetFileId,
117+
'target_file_name' => $targetFileName,
118+
'target_file_path' => $targetFilePath,
119+
'task_id' => $taskId,
120+
'target' => $this->getDefaultTarget($taskId),
121+
];
122+
$taskSuccessful = $targetFileId !== null && $targetFileName !== null;
123+
124+
$subject = $taskSuccessful
125+
? 'new_image_file_success'
126+
: 'new_image_file_failure';
127+
128+
$notification->setApp(Application::APP_ID)
129+
->setUser($userId)
130+
->setDateTime(new DateTime())
131+
->setObject('task', (string)$taskId)
132+
->setSubject($subject, $params);
133+
134+
$manager->notify($notification);
135+
}
103136
}

src/assistant.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -521,15 +521,15 @@ export async function openAssistantTask(
521521
let lastTask = task
522522

523523
modalMountPoint.addEventListener('cancel', () => {
524-
view.unmount()
524+
app.unmount()
525525
})
526526
modalMountPoint.addEventListener('submit', (data) => {
527527
scheduleTask(task.appId, task.identifier ?? '', data.detail.selectedTaskTypeId, data.detail.inputs)
528528
.then((response) => {
529529
console.debug('scheduled task', response.data?.ocs?.data?.task)
530530
})
531531
.catch(error => {
532-
view.unmount()
532+
app.unmount()
533533
console.error('Assistant scheduling error', error)
534534
showError(
535535
t('assistant', 'Assistant failed to schedule your task')
@@ -580,7 +580,7 @@ export async function openAssistantTask(
580580
})
581581
})
582582
.catch(error => {
583-
view.unmount()
583+
app.unmount()
584584
console.error('Assistant scheduling error', error)
585585
showError(t('assistant', 'Assistant error') + ': ' + error?.response?.data)
586586
// reject(new Error('Assistant scheduling error'))
@@ -694,7 +694,7 @@ export async function openAssistantTask(
694694
lastTask.output = data.detail.output
695695
data.detail.button.onClick(lastTask)
696696
}
697-
view.unmount()
697+
app.unmount()
698698
})
699699
}
700700

0 commit comments

Comments
 (0)