Skip to content

Commit ccda174

Browse files
authored
Merge pull request #228 from nextcloud/enh/noid/task-output-file-reference
Task output file download link reference
2 parents 0248ad3 + 7800142 commit ccda174

14 files changed

+548
-27
lines changed

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
['name' => 'assistantApi#displayUserFile', 'url' => '/api/{apiVersion}/file/{fileId}/display', 'verb' => 'GET', 'requirements' => $requirements],
3030
['name' => 'assistantApi#getUserFileInfo', 'url' => '/api/{apiVersion}/file/{fileId}/info', 'verb' => 'GET', 'requirements' => $requirements],
3131
['name' => 'assistantApi#shareOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/file/{fileId}/share', 'verb' => 'POST', 'requirements' => $requirements],
32+
['name' => 'assistantApi#saveOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/file/{fileId}/save', 'verb' => 'POST', 'requirements' => $requirements],
3233
['name' => 'assistantApi#getOutputFilePreview', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/preview', 'verb' => 'GET', 'requirements' => $requirements],
3334
['name' => 'assistantApi#getOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/download', 'verb' => 'GET', 'requirements' => $requirements],
3435

lib/AppInfo/Application.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
use OCA\Assistant\Listener\FreePrompt\FreePromptReferenceListener;
1515
use OCA\Assistant\Listener\SpeechToText\SpeechToTextReferenceListener;
1616
use OCA\Assistant\Listener\TaskFailedListener;
17+
use OCA\Assistant\Listener\TaskOutputFileReferenceListener;
1718
use OCA\Assistant\Listener\TaskSuccessfulListener;
1819
use OCA\Assistant\Listener\Text2Image\Text2ImageReferenceListener;
1920
use OCA\Assistant\Notification\Notifier;
2021
use OCA\Assistant\Reference\FreePromptReferenceProvider;
2122
use OCA\Assistant\Reference\SpeechToTextReferenceProvider;
23+
use OCA\Assistant\Reference\TaskOutputFileReferenceProvider;
2224
use OCA\Assistant\Reference\Text2ImageReferenceProvider;
2325
use OCP\AppFramework\App;
2426
use OCP\AppFramework\Bootstrap\IBootContext;
@@ -52,10 +54,12 @@ public function register(IRegistrationContext $context): void {
5254
$context->registerReferenceProvider(Text2ImageReferenceProvider::class);
5355
$context->registerReferenceProvider(FreePromptReferenceProvider::class);
5456
$context->registerReferenceProvider(SpeechToTextReferenceProvider::class);
57+
$context->registerReferenceProvider(TaskOutputFileReferenceProvider::class);
5558

5659
$context->registerEventListener(RenderReferenceEvent::class, Text2ImageReferenceListener::class);
5760
$context->registerEventListener(RenderReferenceEvent::class, FreePromptReferenceListener::class);
5861
$context->registerEventListener(RenderReferenceEvent::class, SpeechToTextReferenceListener::class);
62+
$context->registerEventListener(RenderReferenceEvent::class, TaskOutputFileReferenceListener::class);
5963

6064
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
6165

lib/Controller/AssistantApiController.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,13 +234,13 @@ public function getUserFileInfo(int $fileId): DataResponse {
234234
/**
235235
* Share an output file
236236
*
237-
* Share a file that was produced by a task
237+
* Save and share a file that was produced by a task
238238
*
239239
* @param int $ocpTaskId The task ID
240240
* @param int $fileId The file ID
241241
* @return DataResponse<Http::STATUS_OK, array{shareToken: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
242242
*
243-
* 200: The file was shared
243+
* 200: The file was saved and shared
244244
* 404: The file was not found
245245
*/
246246
#[NoAdminRequired]
@@ -254,6 +254,29 @@ public function shareOutputFile(int $ocpTaskId, int $fileId): DataResponse {
254254
}
255255
}
256256

257+
/**
258+
* Save an output file
259+
*
260+
* Save a file that was produced by a task
261+
*
262+
* @param int $ocpTaskId The task ID
263+
* @param int $fileId The file ID
264+
* @return DataResponse<Http::STATUS_OK, array{shareToken: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
265+
*
266+
* 200: The file was saved
267+
* 404: The file was not found
268+
*/
269+
#[NoAdminRequired]
270+
public function saveOutputFile(int $ocpTaskId, int $fileId): DataResponse {
271+
try {
272+
$info = $this->assistantService->saveOutputFile($this->userId, $ocpTaskId, $fileId);
273+
return new DataResponse($info);
274+
} catch (\Exception $e) {
275+
$this->logger->error('Failed to save assistant output file', ['exception' => $e]);
276+
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND);
277+
}
278+
}
279+
257280
/**
258281
* Get task output file preview
259282
*

lib/Listener/BeforeTemplateRenderedListener.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ public function handle(Event $event): void {
6464
$indexingComplete = $this->appConfig->getValueInt('context_chat', 'last_indexed_time', 0) !== 0;
6565
$this->initialStateService->provideInitialState('contextChatIndexingComplete', $indexingComplete);
6666
}
67+
if (class_exists(\OCA\Viewer\Event\LoadViewer::class)) {
68+
$this->eventDispatcher->dispatchTyped(new \OCA\Viewer\Event\LoadViewer());
69+
}
6770
Util::addScript(Application::APP_ID, Application::APP_ID . '-main');
6871
}
6972
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 OCP\Collaboration\Reference\RenderReferenceEvent;
12+
use OCP\EventDispatcher\Event;
13+
use OCP\EventDispatcher\IEventListener;
14+
use OCP\Util;
15+
16+
/**
17+
* @implements IEventListener<RenderReferenceEvent>
18+
*/
19+
class TaskOutputFileReferenceListener implements IEventListener {
20+
public function __construct(
21+
) {
22+
}
23+
24+
public function handle(Event $event): void {
25+
if (!$event instanceof RenderReferenceEvent) {
26+
return;
27+
}
28+
29+
Util::addScript(Application::APP_ID, Application::APP_ID . '-taskOutputFileReference');
30+
}
31+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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\Reference;
9+
10+
use OCA\Assistant\AppInfo\Application;
11+
use OCP\Collaboration\Reference\IReference;
12+
use OCP\Collaboration\Reference\IReferenceManager;
13+
use OCP\Collaboration\Reference\IReferenceProvider;
14+
use OCP\Collaboration\Reference\LinkReferenceProvider;
15+
use OCP\Collaboration\Reference\Reference;
16+
use OCP\IURLGenerator;
17+
use OCP\TaskProcessing\IManager as TaskProcessingManager;
18+
19+
class TaskOutputFileReferenceProvider implements IReferenceProvider {
20+
21+
private const RICH_OBJECT_TYPE = Application::APP_ID . '_task-output-file';
22+
23+
public function __construct(
24+
private IReferenceManager $referenceManager,
25+
private LinkReferenceProvider $linkReferenceProvider,
26+
private IURLGenerator $urlGenerator,
27+
private TaskProcessingManager $taskProcessingManager,
28+
private ?string $userId,
29+
) {
30+
}
31+
32+
/**
33+
* @inheritDoc
34+
*/
35+
public function matchReference(string $referenceText): bool {
36+
return $this->getLinkInfo($referenceText) !== null;
37+
}
38+
39+
/**
40+
* @inheritDoc
41+
*/
42+
public function resolveReference(string $referenceText): ?IReference {
43+
if ($this->matchReference($referenceText)) {
44+
$linkInfo = $this->getLinkInfo($referenceText);
45+
if ($linkInfo !== null) {
46+
$taskId = $linkInfo['taskId'];
47+
$task = $this->taskProcessingManager->getTask($taskId);
48+
if ($task->getUserId() === null || $task->getUserId() !== $this->userId) {
49+
return null;
50+
}
51+
52+
$linkInfo['taskTypeId'] = $task->getTaskTypeId();
53+
$linkInfo['taskTypeName'] = $this->taskProcessingManager->getAvailableTaskTypes()[$task->getTaskTypeId()]['name'] ?? null;
54+
$reference = new Reference($referenceText);
55+
$reference->setRichObject(
56+
self::RICH_OBJECT_TYPE,
57+
$linkInfo,
58+
);
59+
return $reference;
60+
}
61+
// fallback to opengraph
62+
return $this->linkReferenceProvider->resolveReference($referenceText);
63+
}
64+
65+
return null;
66+
}
67+
68+
/**
69+
* @param string $url
70+
* @return array|null
71+
*/
72+
private function getLinkinfo(string $url): ?array {
73+
// assistant download link
74+
// https://nextcloud.local/ocs/v2.php/apps/assistant/api/v1/task/42/output-file/398/download
75+
76+
$start = $this->urlGenerator->linkToOCSRouteAbsolute(Application::APP_ID . '.assistantApi.getOutputFile', [
77+
'apiVersion' => 'v1',
78+
'ocpTaskId' => 123,
79+
'fileId' => 123,
80+
]);
81+
$start = str_replace('/task/123/output-file/123/download', '/task/', $start);
82+
if (str_starts_with($url, $start)) {
83+
preg_match('/\/task\/(\d+)\/output-file\/(\d+)\/download$/i', $url, $matches);
84+
if (count($matches) > 2) {
85+
return [
86+
'taskId' => (int)$matches[1],
87+
'fileId' => (int)$matches[2],
88+
];
89+
}
90+
}
91+
92+
// task processing download links
93+
// https://nextcloud.local/ocs/v2.php/taskprocessing/tasks/42/file/398
94+
95+
$start = $this->urlGenerator->linkToOCSRouteAbsolute('core.taskProcessingApi.getFileContents', [
96+
'taskId' => 123,
97+
'fileId' => 123,
98+
]);
99+
$start = str_replace('/tasks/123/file/123', '/tasks/', $start);
100+
if (str_starts_with($url, $start)) {
101+
preg_match('/\/tasks\/(\d+)\/file\/(\d+)$/i', $url, $matches);
102+
if (count($matches) > 2) {
103+
return [
104+
'taskId' => (int)$matches[1],
105+
'fileId' => (int)$matches[2],
106+
];
107+
}
108+
}
109+
110+
return null;
111+
}
112+
113+
/**
114+
* We use the userId here because when connecting/disconnecting from the GitHub account,
115+
* we want to invalidate all the user cache and this is only possible with the cache prefix
116+
* @inheritDoc
117+
*/
118+
public function getCachePrefix(string $referenceId): string {
119+
return $this->userId ?? '';
120+
}
121+
122+
/**
123+
* We don't use the userId here but rather a reference unique id
124+
* @inheritDoc
125+
*/
126+
public function getCacheKey(string $referenceId): ?string {
127+
return $referenceId;
128+
}
129+
130+
/**
131+
* @param string $userId
132+
* @return void
133+
*/
134+
public function invalidateUserCache(string $userId): void {
135+
$this->referenceManager->invalidateCache($userId);
136+
}
137+
}

lib/Service/AssistantService.php

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ public function getTaskOutputFile(string $userId, int $ocpTaskId, int $fileId):
391391
* @param string $userId
392392
* @param int $ocpTaskId
393393
* @param int $fileId
394-
* @return string
394+
* @return File
395395
* @throws Exception
396396
* @throws InvalidPathException
397397
* @throws LockedException
@@ -402,34 +402,59 @@ public function getTaskOutputFile(string $userId, int $ocpTaskId, int $fileId):
402402
* @throws TaskProcessingException
403403
* @throws \OCP\Files\NotFoundException
404404
*/
405-
public function shareOutputFile(string $userId, int $ocpTaskId, int $fileId): string {
405+
private function saveFile(string $userId, int $ocpTaskId, int $fileId): File {
406406
$taskOutputFile = $this->getTaskOutputFile($userId, $ocpTaskId, $fileId);
407407
$assistantDataFolder = $this->getAssistantDataFolder($userId);
408408
$targetFileName = $this->getTargetFileName($taskOutputFile);
409409
if ($assistantDataFolder->nodeExists($targetFileName)) {
410410
$existingTarget = $assistantDataFolder->get($targetFileName);
411411
if ($existingTarget instanceof File) {
412412
if ($existingTarget->getSize() === $taskOutputFile->getSize()) {
413-
$fileCopy = $existingTarget;
413+
return $existingTarget;
414414
} else {
415-
$fileCopy = $assistantDataFolder->newFile($targetFileName, $taskOutputFile->fopen('rb'));
415+
return $assistantDataFolder->newFile($targetFileName, $taskOutputFile->fopen('rb'));
416416
}
417417
} else {
418418
throw new Exception('Impossible to copy output file, a directory with this name already exists', Http::STATUS_UNAUTHORIZED);
419419
}
420420
} else {
421-
$fileCopy = $assistantDataFolder->newFile($targetFileName, $taskOutputFile->fopen('rb'));
421+
return $assistantDataFolder->newFile($targetFileName, $taskOutputFile->fopen('rb'));
422422
}
423+
}
424+
425+
/**
426+
* @param string $userId
427+
* @param int $ocpTaskId
428+
* @param int $fileId
429+
* @return string
430+
* @throws Exception
431+
* @throws InvalidPathException
432+
* @throws LockedException
433+
* @throws NoUserException
434+
* @throws NotFoundException
435+
* @throws NotPermittedException
436+
* @throws PreConditionNotMetException
437+
* @throws TaskProcessingException
438+
* @throws \OCP\Files\NotFoundException
439+
*/
440+
public function shareOutputFile(string $userId, int $ocpTaskId, int $fileId): string {
441+
$fileCopy = $this->saveFile($userId, $ocpTaskId, $fileId);
423442
$share = $this->shareManager->newShare();
424443
$share->setNode($fileCopy);
425444
$share->setPermissions(Constants::PERMISSION_READ);
426445
$share->setShareType(IShare::TYPE_LINK);
427446
$share->setSharedBy($userId);
428447
$share->setLabel('Assistant share');
429448
$share = $this->shareManager->createShare($share);
430-
$shareToken = $share->getToken();
449+
return $share->getToken();
450+
}
431451

432-
return $shareToken;
452+
public function saveOutputFile(string $userId, int $ocpTaskId, int $fileId): array {
453+
$fileCopy = $this->saveFile($userId, $ocpTaskId, $fileId);
454+
return [
455+
'fileId' => $fileCopy->getId(),
456+
'path' => preg_replace('/^files\//', '/', $fileCopy->getInternalPath()),
457+
];
433458
}
434459

435460
/**

0 commit comments

Comments
 (0)