Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,12 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalScriptsListener::class);

$context->registerEventListener(TaskSuccessfulEvent::class, TaskSuccessfulListener::class);
$context->registerEventListener(TaskFailedEvent::class, TaskFailedListener::class);
// the chat listener persists the generated reply before the notify
// listeners run: a ready notification must not beat the message insert
$context->registerEventListener(TaskSuccessfulEvent::class, ChattyLLMTaskListener::class);
$context->registerEventListener(TaskFailedEvent::class, ChattyLLMTaskListener::class);
$context->registerEventListener(TaskSuccessfulEvent::class, TaskSuccessfulListener::class);
$context->registerEventListener(TaskFailedEvent::class, TaskFailedListener::class);
$context->registerEventListener(TaskSuccessfulEvent::class, FileActionTaskSuccessfulListener::class);
$context->registerEventListener(TaskSuccessfulEvent::class, NewFileMenuTaskSuccessfulListener::class);
$context->registerEventListener(TaskFailedEvent::class, FileActionTaskFailedListener::class);
Expand Down
3 changes: 2 additions & 1 deletion lib/Listener/TaskFailedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public function handle(Event $event): void {

// we dispatch an event to ask the app that scheduled the task if it wants a notification
// and what the target should be
if ($task->getAppId() !== Application::APP_ID) {
// chat tasks are scheduled by the assistant itself, with a compound appId
if ($task->getAppId() !== Application::APP_ID && $task->getAppId() !== Application::APP_ID . ':chatty-llm') {
$beforeAssistantNotificationEvent = new BeforeAssistantNotificationEvent($task);
$this->eventDispatcher->dispatchTyped($beforeAssistantNotificationEvent);
if (!$beforeAssistantNotificationEvent->getWantsNotification()) {
Expand Down
3 changes: 2 additions & 1 deletion lib/Listener/TaskSuccessfulListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public function handle(Event $event): void {

// we dispatch an event to ask the app that scheduled the task if it wants a notification
// and what the target should be
if ($task->getAppId() !== Application::APP_ID) {
// chat tasks are scheduled by the assistant itself, with a compound appId
if ($task->getAppId() !== Application::APP_ID && $task->getAppId() !== Application::APP_ID . ':chatty-llm') {
Comment thread
WSHAPER marked this conversation as resolved.
$beforeAssistantNotificationEvent = new BeforeAssistantNotificationEvent($task);
$this->eventDispatcher->dispatchTyped($beforeAssistantNotificationEvent);
if (!$beforeAssistantNotificationEvent->getWantsNotification()) {
Expand Down
49 changes: 47 additions & 2 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ public function prepare(INotification $notification, string $languageCode): INot

if (in_array($notification->getSubject(), ['success', 'failure'], true)) {
// ignore old notifications (before meta tasks were introduced)
if (!isset($params['target'], $params['inputs'])) {
// the target is optional: the link is generated at render time
if (!isset($params['inputs'])) {
throw new UnknownNotificationException();
}
$schedulingAppId = $params['appId'];
Expand Down Expand Up @@ -136,7 +137,7 @@ public function prepare(INotification $notification, string $languageCode): INot
$content .= $l->t('Result: %1$s', [$params['result']]);
}

$link = $params['target'];
$link = $params['target'] ?? $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantTaskResultPage', ['taskId' => $params['id']]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
$iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'));

$notification
Expand Down Expand Up @@ -409,6 +410,50 @@ public function prepare(INotification $notification, string $languageCode): INot

$notification->addParsedAction($action);

return $notification;
case 'chat_success':
$subject = $l->t('Your assistant reply is ready');

$link = $params['target']
?? $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantStandalonePage', ['sessionId' => $params['sessionId']]);
$iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'));

$notification
->setParsedSubject($subject)
->setLink($link)
->setIcon($iconUrl);

$actionLabel = $params['actionLabel'] ?? $l->t('View reply');
$action = $notification->createAction();
$action->setLabel($actionLabel)
->setParsedLabel($actionLabel)
->setLink($notification->getLink(), IAction::TYPE_WEB)
->setPrimary(true);

$notification->addParsedAction($action);

return $notification;
case 'chat_failure':
$subject = $l->t('Your assistant reply could not be generated');

$link = $params['target']
?? $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantStandalonePage', ['sessionId' => $params['sessionId']]);
$iconUrl = $this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/error.svg'));

$notification
->setParsedSubject($subject)
->setLink($link)
->setIcon($iconUrl);

$actionLabel = $params['actionLabel'] ?? $l->t('View chat');
$action = $notification->createAction();
$action->setLabel($actionLabel)
->setParsedLabel($actionLabel)
->setLink($notification->getLink(), IAction::TYPE_WEB)
->setPrimary(true);

$notification->addParsedAction($action);

return $notification;
default:
// Unknown subject => Unknown notification => throw
Expand Down
50 changes: 42 additions & 8 deletions lib/Service/NotificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,45 @@ public function sendNotification(Task $task, ?string $customTarget = null, ?stri
$manager = $this->notificationManager;
$notification = $manager->createNotification();

$chatSessionId = $this->getChatMessageSessionId($task);

$params = [
'appId' => $task->getAppId(),
'id' => $task->getId(),
'inputs' => $task->getInput(),
'target' => $customTarget ?? $this->getDefaultTarget($task->getId()),
'target' => $customTarget,
'actionLabel' => $actionLabel,
'result' => $resultPreview,
];
if ($chatSessionId === null) {
// chat notifications never render the task input and it can be
// large enough to overflow the notifications table column
$params['inputs'] = $task->getInput();
}
$params['taskTypeId'] = $task->getTaskTypeId();
if ($chatSessionId !== null) {
$params['sessionId'] = (string)$chatSessionId;
}

$taskSuccessful = $task->getStatus() === Task::STATUS_SUCCESSFUL;

$subject = $taskSuccessful
? 'success'
: 'failure';
if ($chatSessionId !== null) {
$subject = $taskSuccessful
? 'chat_success'
: 'chat_failure';
} else {
$subject = $taskSuccessful
? 'success'
: 'failure';
}

$objectType = $customTarget === null
? 'task'
: 'task-with-custom-target';
// chat conversations must navigate to the session, not open the task
// result modal: the frontend notification action interceptor cancels
// WEB actions for notifications with the object type "task"
$objectType = $chatSessionId !== null
? 'chat'
: ($customTarget === null
? 'task'
: 'task-with-custom-target');

$notification->setApp(Application::APP_ID)
->setUser($task->getUserId())
Expand All @@ -68,6 +88,20 @@ private function getDefaultTarget(int $taskId): string {
return $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantTaskResultPage', ['taskId' => $taskId]);
}

/**
* Chat message generation tasks share their appId with title generation tasks,
* only message tasks (including regenerations) use a "chatty-llm:" prefixed customId
*/
private function getChatMessageSessionId(Task $task): ?int {
if ($task->getAppId() !== Application::APP_ID . ':chatty-llm') {
return null;
}
if (preg_match('/^chatty-llm:(\d+)/', $task->getCustomId() ?? '', $matches)) {
return (int)$matches[1];
}
return null;
}

public function sendFileActionNotification(
string $userId, string $taskTypeId, int $taskId,
int $sourceFileId, string $sourceFileName, string $sourceFilePath,
Expand Down
8 changes: 6 additions & 2 deletions src/components/AssistantTextProcessingForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,14 @@ export default {
'background-notify',
],
data() {
const urlIsAssignment = new URLSearchParams(window.location.search).get('isAssignment')
const urlParams = new URLSearchParams(window.location.search)
const urlIsAssignment = urlParams.get('isAssignment')
const urlSessionId = urlParams.get('sessionId')
// a session deep-link (chat notification) only makes sense in the chat view;
// isAssignment links also carry a sessionId and must keep winning
const urlSelectedTaskTypeId = urlIsAssignment !== null && urlIsAssignment !== '0' && urlIsAssignment !== 'false'
? 'assignments'
: null
: (urlSessionId !== null ? CHAT_TASK_TYPE_ID : null)
return {
myInputs: this.inputs,
myOutputs: this.outputs,
Expand Down
88 changes: 88 additions & 0 deletions src/components/ChattyLLM/ChattyLLMInputForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,10 @@
v-model:chat-content="chatContent"
class="session-area__input-area"
:loading="loading"
:is-notify-enabled="isNotifyEnabled"
@submit="handleSubmit"
@submit-audio="handleSubmitAudio"
@background-notify="onBackgroundNotify"
@cancel-generation="cancelGeneration" />
</NcAppContent>
<NcDialog :key="deletionDialogKey"
Expand Down Expand Up @@ -297,11 +299,14 @@ import { getLanguage } from '@nextcloud/l10n'
import {
cancelTask,
cancelTaskPositionPolling,
getNotifyReady,
pollTaskPosition,
setNotifyReady,
TaskPollCancelledError,
} from '../../assistant.js'

import navAutoCollapse from '../../mixins/navAutoCollapse.js'
import { requestBrowserNotificationPermission } from '../../utils.js'

// future: type (text, image, file, etc), attachments, etc support

Expand Down Expand Up @@ -375,6 +380,13 @@ export default {
pendingCancelGeneration: false,
pendingCancelSeq: null,
generationRequestSeq: 0,
// notify-when-ready toggle state of the tracked generation task
isNotifyEnabled: false,
// one toggle request at a time: overlapping requests could complete out
// of order server side and desync the toggle state
notifyTogglePending: false,
// bumped when the notify state is synced or toggled, to discard stale sync responses
notifyStateSyncId: 0,
// [{ id: number, session_id: number, role: string, content: string, timestamp: number, sources:string }]
messages: [], // null when failed to fetch
streamingMessage: null,
Expand Down Expand Up @@ -527,6 +539,7 @@ export default {
this.generationTaskId = null
this.pendingCancelGeneration = false
this.pendingCancelSeq = null
this.isNotifyEnabled = false
this.allMessagesLoaded = false
this.loading.llmGeneration = false
this.loading.llmRunning = false
Expand Down Expand Up @@ -632,6 +645,16 @@ export default {
return
}
this.startTaskPositionPolling(checkSessionResponseData.messageTaskId, sessionId)
// resume case: sync the toggle with a notification that was enabled before reloading, don't block the polling
const notifyStateSyncId = ++this.notifyStateSyncId
getNotifyReady(checkSessionResponseData.messageTaskId).then(response => {
if (this.generationTaskId === checkSessionResponseData.messageTaskId
&& this.notifyStateSyncId === notifyStateSyncId) {
this.isNotifyEnabled = !!response.data?.ocs?.data?.id
}
}).catch(error => {
console.error('Failed to get the notification state of the generation task', error)
})
this.userScrolled = false
const message = await this.pollGenerationTask(checkSessionResponseData.messageTaskId, sessionId)
console.debug('checkTaskPolling result:', message)
Expand Down Expand Up @@ -686,6 +709,7 @@ export default {
if (ownsGeneration) {
this.generationTaskId = null
}
this.isNotifyEnabled = false
this.loading.llmGeneration = false
this.loading.llmRunning = false
this.loading.taskPosition = null
Expand Down Expand Up @@ -929,6 +953,7 @@ export default {
/**
* Track the running generation task of a session. A stop request made
* before the task id was known is executed once the id is available.
* A fresh generation also resets the notify-when-ready toggle.
*
* @param {number} taskId Id of the scheduled generation task
* @param {number} sessionId Id of the session the task belongs to
Expand All @@ -942,6 +967,7 @@ export default {
return false
}
this.generationTaskId = taskId
this.isNotifyEnabled = false
if (this.pendingCancelGeneration && this.pendingCancelSeq === requestSeq) {
this.pendingCancelGeneration = false
this.pendingCancelSeq = null
Expand Down Expand Up @@ -1246,6 +1272,7 @@ export default {
if (taskId !== null) {
this.generationTaskId = null
}
this.isNotifyEnabled = false
this.loading.llmGeneration = false
this.loading.llmRunning = false
this.loading.taskPosition = null
Expand Down Expand Up @@ -1314,6 +1341,7 @@ export default {
if (taskId !== null) {
this.generationTaskId = null
}
this.isNotifyEnabled = false
this.loading.llmGeneration = false
this.loading.llmRunning = false
this.loading.taskPosition = null
Expand Down Expand Up @@ -1358,6 +1386,66 @@ export default {
return hasPush
},

/**
* Enable or disable the notification for the currently running generation task.
*/
async onBackgroundNotify() {
if (this.generationTaskId === null || !this.loading.llmGeneration || this.notifyTogglePending) {
return
}
// capture before the optional permission prompt: it can stay open long
// enough for the generation to end or the session to switch
const taskId = this.generationTaskId
// one id per toggle request: in-flight sync responses, stale request
// responses and the reload timer must not apply after a newer toggle
const notifyStateSyncId = ++this.notifyStateSyncId
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const enable = !this.isNotifyEnabled
let reloadForPushSubscription = false
// serialize: overlapping requests could complete out of order server
// side and desync the toggle state
this.notifyTogglePending = true
try {
if (enable) {
// user gesture context: ask for browser notifications so the reply can also
// reach the user when the tab is closed
reloadForPushSubscription = await requestBrowserNotificationPermission()
// the prompt can outlive the generation: if the tracked task ended or
// was replaced while it was open, applying the toggle would target
// the wrong task and desync the banner state
if (this.generationTaskId !== taskId) {
return
}
}
await setNotifyReady(taskId, enable)
// a slow request can outlive its generation or be superseded by a
// newer state sync: applying it then would flip the state of
// whatever is tracked now
if (this.generationTaskId !== taskId || this.notifyStateSyncId !== notifyStateSyncId) {
return
}
this.isNotifyEnabled = enable
// the notifications app registers web push on page load only:
// reload once after a fresh grant so browser delivery actually starts.
// The generation survives the reload (check_session resumes it).
if (reloadForPushSubscription && enable) {
setTimeout(() => {
// a toggle back to disabled may still be in flight: don't
// reload over it
if (this.isNotifyEnabled && this.notifyStateSyncId === notifyStateSyncId) {
window.location.reload()
Comment thread
WSHAPER marked this conversation as resolved.
}
}, 1000)
}
} catch (error) {
console.error('Failed to toggle the notification of the generation task', error)
if (enable) {
showError(t('assistant', 'Failed to enable the notification'))
}
} finally {
this.notifyTogglePending = false
}
},

startTaskPositionPolling(taskId, sessionId) {
if (this.pollPositionTaskId === taskId && this.pollPositionSessionId === sessionId) {
return
Expand Down
Loading
Loading