From 82e3e2f304b9129d5b17fc96be370daca9a62f7c Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 23 Jul 2026 13:39:35 +0200 Subject: [PATCH 01/13] feat(position): display the task's current position in the queue Signed-off-by: Julien Veyssier --- src/assistant.js | 110 ++++++++++++++++++ .../AssistantTextProcessingForm.vue | 5 + .../AssistantTextProcessingModal.vue | 2 + src/components/RunningEmptyContent.vue | 24 +++- src/views/AssistantPage.vue | 28 +++++ 5 files changed, 167 insertions(+), 2 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index cf0273f3..8b6aa4dd 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -11,6 +11,7 @@ import Aura from '@primeuix/themes/aura' import { listen } from '@nextcloud/notify_push' window.assistantPollTimerId = null +window.assistantPollPositionTimerId = null listen('taskprocessing:task_update', (type, body) => { console.debug('[assistant] received task update push notification', type, body) @@ -168,6 +169,7 @@ export async function openAssistantForm({ modalMountPoint.addEventListener('cancel', () => { cancelTaskPolling() + cancelTaskPositionPolling() app.unmount() OCA.Assistant.isAssistantDialogOpen = false reject(new Error('User cancellation')) @@ -175,6 +177,7 @@ export async function openAssistantForm({ const syncSubmit = (inputs, taskTypeId, newTaskCustomId = '') => { view.loading = true view.showSyncTaskRunning = true + view.taskPosition = null view.isNotifyEnabled = false view.progress = null view.expectedRuntime = null @@ -196,6 +199,11 @@ export async function openAssistantForm({ const hasPush = listenToTaskNotifications(task.id) console.debug('[assistant] HAS PUSH', hasPush) + pollTaskPosition(task.id, view).then(() => { + console.debug('[assistant] pollTaskPosition finished') + }).catch(error => { + console.debug('[assistant] pollPosition error', error.message) + }) // no need to update the task output with polling if we have push notifications pollTask(task.id, view, !hasPush).then(finishedTask => { console.debug('pollTask.then', finishedTask) @@ -224,12 +232,14 @@ export async function openAssistantForm({ resolve(finishedTask) view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error.message) if (error.message === 'task-not-found') { view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -241,6 +251,7 @@ export async function openAssistantForm({ .catch(error => { view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null console.error('Assistant scheduling error', error?.response?.data?.ocs?.data?.message) showError(t('assistant', 'Assistant error') + ': ' + t('assistant', 'Something went wrong when scheduling the task')) }) @@ -258,7 +269,9 @@ export async function openAssistantForm({ const task = data.detail console.debug('[assistant] loading task', task) cancelTaskPolling() + cancelTaskPositionPolling() view.showSyncTaskRunning = false + view.taskPosition = null view.isNotifyEnabled = false view.loading = false view.taskStatus = task.status @@ -291,6 +304,7 @@ export async function openAssistantForm({ view.loading = true view.showSyncTaskRunning = true + view.taskPosition = null view.progress = null view.expectedRuntime = (updatedTask?.completionExpectedAt - updatedTask?.scheduledAt) || null view.startedAt = lastTask?.startedAt || null @@ -299,6 +313,11 @@ export async function openAssistantForm({ const hasPush = listenToTaskNotifications(task.id) console.debug('[assistant] HAS PUSH', hasPush) + pollTaskPosition(updatedTask.id, view).then(() => { + console.debug('[assistant] pollTaskPosition finished') + }).catch(error => { + console.debug('[assistant] pollPosition error', error.message) + }) pollTask(updatedTask.id, view, !hasPush).then(finishedTask => { console.debug('pollTask.then', finishedTask) if (finishedTask.status === TASK_STATUS_STRING.successful) { @@ -322,12 +341,14 @@ export async function openAssistantForm({ // resolve(finishedTask) view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) if (error.message === 'task-not-found') { view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -343,8 +364,10 @@ export async function openAssistantForm({ modalMountPoint.addEventListener('new-task', () => { console.debug('[assistant] new task') cancelTaskPolling() + cancelTaskPositionPolling() view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -358,10 +381,12 @@ export async function openAssistantForm({ }) modalMountPoint.addEventListener('cancel-task', () => { cancelTaskPolling() + cancelTaskPositionPolling() setNotifyReady(lastTask.id, false) cancelTask(lastTask.id).then(res => { view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null view.selectedTaskId = null view.outputs = null view.taskStatus = null @@ -393,6 +418,55 @@ function updateTask(task, object, updateOutput = true) { object.completionExpectedAt = task?.completionExpectedAt } +function updateTaskPosition(position, object) { + object.taskPosition = position +} + +/** + * Poll the task position + * + * @param {number} taskId the task ID + * @param {object} obj the object to update + * @param {Function} callback the function to call to update the object + * @return {Promise<*>} + */ +export async function pollTaskPosition(taskId, obj, callback = updateTaskPosition) { + return new Promise((resolve, reject) => { + const pollPositionOnce = () => { + if (window.assistantPollPositionTimerId === null) { + reject(new Error('pollTaskPosition cancelled')) + return + } + getTaskPosition(taskId).then(response => { + const taskPosition = response.data?.ocs?.data + if (window.assistantPollPositionTimerId === null) { + reject(new Error('pollTaskPosition cancelled')) + return + } + if (obj) { + callback(taskPosition, obj) + } + }).catch(error => { + console.debug('[assistant] pollPosition request failed', error) + clearInterval(window.assistantPollPositionTimerId) + window.assistantPollPositionTimerId = null + if (error.status === 404) { + reject(new Error('task-not-found')) + return + } else if (error.status === 412) { + // the task is not scheduled anymore + resolve() + return + } + reject(new Error('pollTaskPosition request failed')) + }) + } + // start polling immediately + pollPositionOnce() + window.assistantPollPositionTimerId = setInterval(pollPositionOnce, 5000) + }) +} + /** * Poll the task to update its status * @@ -443,6 +517,11 @@ export async function cancelTaskPolling() { window.assistantPollTimerId = null } +export async function cancelTaskPositionPolling() { + clearInterval(window.assistantPollPositionTimerId) + window.assistantPollPositionTimerId = null +} + export async function getTask(taskId) { window.assistantAbortController = new AbortController() const { default: axios } = await import('@nextcloud/axios') @@ -451,6 +530,13 @@ export async function getTask(taskId) { return axios.get(url, { signal: window.assistantAbortController.signal }) } +export async function getTaskPosition(taskId) { + const { default: axios } = await import('@nextcloud/axios') + const { generateOcsUrl } = await import('@nextcloud/router') + const url = generateOcsUrl('taskprocessing/tasks/{taskId}/queue_position', { taskId }) + return axios.get(url, {}) +} + export async function getNotifyReady(taskId) { const { default: axios } = await import('@nextcloud/axios') const { generateOcsUrl } = await import('@nextcloud/router') @@ -688,6 +774,7 @@ export async function openAssistantTask( modalMountPoint.addEventListener('cancel', () => { cancelTaskPolling() + cancelTaskPositionPolling() app.unmount() OCA.Assistant.isAssistantDialogOpen = false }) @@ -709,6 +796,7 @@ export async function openAssistantTask( const syncSubmit = (inputs, taskTypeId, newTaskCustomId = '') => { view.loading = true view.showSyncTaskRunning = true + view.taskPosition = null view.isNotifyEnabled = false view.expectedRuntime = null view.startedAt = null @@ -728,6 +816,11 @@ export async function openAssistantTask( const hasPush = listenToTaskNotifications(task.id) console.debug('[assistant] HAS PUSH', hasPush) + pollTaskPosition(task.id, view).then(() => { + console.debug('[assistant] pollTaskPosition finished') + }).catch(error => { + console.debug('[assistant] pollPosition error', error.message) + }) pollTask(task.id, view, !hasPush).then(finishedTask => { if (finishedTask.status === TASK_STATUS_STRING.successful) { view.outputs = finishedTask?.output @@ -749,6 +842,7 @@ export async function openAssistantTask( // resolve(finishedTask) view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) @@ -756,6 +850,7 @@ export async function openAssistantTask( if (error.message === 'task-not-found') { view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null view.isNotifyEnabled = false view.selectedTaskId = null lastTask = null @@ -766,6 +861,7 @@ export async function openAssistantTask( .catch(error => { view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null console.error('Assistant scheduling error', error?.response?.data?.ocs?.data?.message) showError(t('assistant', 'Assistant error') + ': ' + t('assistant', 'Something went wrong when scheduling the task')) }) @@ -780,7 +876,9 @@ export async function openAssistantTask( modalMountPoint.addEventListener('load-task', (data) => { const task = data.detail cancelTaskPolling() + cancelTaskPositionPolling() view.showSyncTaskRunning = false + view.taskPosition = null view.isNotifyEnabled = false view.loading = false view.taskStatus = task.status @@ -813,6 +911,7 @@ export async function openAssistantTask( view.loading = true view.showSyncTaskRunning = true + view.taskPosition = null view.progress = null view.expectedRuntime = (updatedTask?.completionExpectedAt - updatedTask?.scheduledAt) || null view.startedAt = lastTask?.startedAt || null @@ -820,6 +919,11 @@ export async function openAssistantTask( const hasPush = listenToTaskNotifications(task.id) + pollTaskPosition(updatedTask.id, view).then(() => { + console.debug('[assistant] pollTaskPosition finished') + }).catch(error => { + console.debug('[assistant] pollPosition error', error.message) + }) pollTask(updatedTask.id, view, !hasPush).then(finishedTask => { console.debug('pollTask.then', finishedTask) if (finishedTask.status === TASK_STATUS_STRING.successful) { @@ -843,12 +947,14 @@ export async function openAssistantTask( // resolve(finishedTask) view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) if (error.message === 'task-not-found') { view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -864,8 +970,10 @@ export async function openAssistantTask( modalMountPoint.addEventListener('new-task', () => { console.debug('[assistant] new task') cancelTaskPolling() + cancelTaskPositionPolling() view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -879,10 +987,12 @@ export async function openAssistantTask( }) modalMountPoint.addEventListener('cancel-task', () => { cancelTaskPolling() + cancelTaskPositionPolling() setNotifyReady(lastTask.id, false) cancelTask(lastTask.id).then(res => { view.loading = false view.showSyncTaskRunning = false + view.taskPosition = null view.selectedTaskId = null view.outputs = null view.taskStatus = null diff --git a/src/components/AssistantTextProcessingForm.vue b/src/components/AssistantTextProcessingForm.vue index da3c374c..b3433ff8 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -46,6 +46,7 @@ class="running-area" :description="shortInput" :progress="progress" + :task-position="taskPosition" :expected-runtime="expectedRuntime" :started-at="startedAt" :completion-expected-at="completionExpectedAt" @@ -286,6 +287,10 @@ export default { type: Boolean, default: false, }, + taskPosition: { + type: [Number, null], + default: null, + }, shortInput: { type: String, required: true, diff --git a/src/components/AssistantTextProcessingModal.vue b/src/components/AssistantTextProcessingModal.vue index 77a6fc06..5d03194d 100644 --- a/src/components/AssistantTextProcessingModal.vue +++ b/src/components/AssistantTextProcessingModal.vue @@ -34,6 +34,7 @@ :loading="loading" :action-buttons="actionButtons" :show-sync-task-running="showSyncTaskRunning" + :task-position="taskPosition" :short-input="shortInput" :progress="progress" :expected-runtime="expectedRuntime" @@ -135,6 +136,7 @@ export default { completionExpectedAt: null, isNotifyEnabled: false, showSyncTaskRunning: false, + taskPosition: null, showScheduleConfirmation: false, // from props selectedTaskId: this.initSelectedTaskId, diff --git a/src/components/RunningEmptyContent.vue b/src/components/RunningEmptyContent.vue index 9379fce2..d3c1c004 100644 --- a/src/components/RunningEmptyContent.vue +++ b/src/components/RunningEmptyContent.vue @@ -14,8 +14,13 @@ -
- {{ formattedRuntime }} +
+ + {{ formattedRuntime }} + + + {{ formattedPosition }} +
{{ t('assistant', 'This task is running in the background.') }} @@ -83,6 +88,10 @@ export default { type: [Number, null], default: null, }, + taskPosition: { + type: [Number, null], + default: null, + }, expectedRuntime: { type: [Number, null], default: null, @@ -144,6 +153,12 @@ export default { } return t('assistant', 'This may take a few minutes…') }, + formattedPosition() { + if (this.taskPosition === null || this.taskStatus !== TASK_STATUS_STRING.scheduled) { + return '' + } + return t('assistant', 'Task position: {position}', { position: this.taskPosition }) + }, progressMessage() { if (this.taskStatus === TASK_STATUS_STRING.scheduled || this.taskStatus === null) { return t('assistant', 'Waiting…') @@ -208,5 +223,10 @@ export default { .info-text-block { text-align: center; } + + .inline { + display: flex; + gap: 4px; + } } diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index 62fa0c77..36d9964e 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -14,6 +14,7 @@ :selected-task-type-id="task.type" :loading="loading" :show-sync-task-running="showSyncTaskRunning" + :task-position="taskPosition" :short-input="shortInput" :task-status="task.status" :scheduled-at="task.scheduledAt" @@ -46,8 +47,10 @@ import { listen } from '@nextcloud/notify_push' import { cancelTask, cancelTaskPolling, + cancelTaskPositionPolling, getTask, pollTask, + pollTaskPosition, scheduleTask, setNotifyReady, } from '../assistant.js' @@ -69,6 +72,7 @@ export default { return { task: loadState('assistant', 'task'), showSyncTaskRunning: false, + taskPosition: null, progress: null, loading: false, isNotifyEnabled: false, @@ -114,11 +118,13 @@ export default { }, onCancel() { cancelTaskPolling() + cancelTaskPositionPolling() if (this.task?.id) { setNotifyReady(this.task.id, false) cancelTask(this.task.id).then(res => { this.loading = false this.showSyncTaskRunning = false + this.taskPosition = null this.task.id = null this.task.output = null this.task.status = null @@ -127,6 +133,7 @@ export default { // if we ever end up in this state, this helps to recover this.loading = false this.showSyncTaskRunning = false + this.taskPosition = null this.task.id = null this.task.output = null this.task.status = null @@ -154,6 +161,7 @@ export default { syncSubmit(inputs, taskTypeId, newTaskIdentifier = '') { this.loading = true this.showSyncTaskRunning = true + this.taskPosition = null this.isNotifyEnabled = false this.progress = null this.task.completionExpectedAt = null @@ -174,6 +182,11 @@ export default { const hasPush = this.listenToTaskNotifications(task.id) console.debug('[assistant] HAS PUSH', hasPush) + pollTaskPosition(task.id, this).then(() => { + console.debug('[assistant] pollTaskPosition finished') + }).catch(error => { + console.debug('[assistant] pollPosition error', error.message) + }) pollTask(task.id, this, !hasPush, this.updateTask).then(finishedTask => { if (finishedTask.status === TASK_STATUS_STRING.successful) { this.task.output = finishedTask?.output @@ -184,12 +197,14 @@ export default { } this.loading = false this.showSyncTaskRunning = false + this.taskPosition = null emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) if (error.message === 'task-not-found') { this.loading = false this.showSyncTaskRunning = false + this.taskPosition = null this.isNotifyEnabled = false this.task.status = TASK_STATUS_STRING.unknown this.task.output = null @@ -201,6 +216,7 @@ export default { .catch(error => { this.loading = false this.showSyncTaskRunning = false + this.taskPosition = null console.error('Assistant scheduling error', error?.response?.data?.ocs?.data?.message) showError(t('assistant', 'Assistant error') + ': ' + t('assistant', 'Something went wrong when scheduling the task')) }) @@ -226,7 +242,9 @@ export default { }, onLoadTask(task) { cancelTaskPolling() + cancelTaskPositionPolling() this.showSyncTaskRunning = false + this.taskPosition = null this.loading = false this.task.type = task.type @@ -249,6 +267,7 @@ export default { this.loading = true this.showSyncTaskRunning = true + this.taskPosition = null this.progress = null this.task.completionExpectedAt = updatedTask.completionExpectedAt this.task.startedAt = updatedTask.startedAt @@ -256,6 +275,11 @@ export default { const hasPush = this.listenToTaskNotifications(task.id) + pollTaskPosition(updatedTask.id, this).then(() => { + console.debug('[assistant] pollTaskPosition finished') + }).catch(error => { + console.debug('[assistant] pollPosition error', error.message) + }) pollTask(updatedTask.id, this, !hasPush, this.updateTask).then(finishedTask => { console.debug('pollTask.then', finishedTask) if (finishedTask.status === TASK_STATUS_STRING.successful) { @@ -269,12 +293,14 @@ export default { // resolve(finishedTask) this.loading = false this.showSyncTaskRunning = false + this.taskPosition = null emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('Assistant poll error', error) if (error.message === 'task-not-found') { this.loading = false this.showSyncTaskRunning = false + this.taskPosition = null this.isNotifyEnabled = false this.task.status = TASK_STATUS_STRING.unknown this.task.output = null @@ -289,8 +315,10 @@ export default { }, onNewTask() { cancelTaskPolling() + cancelTaskPositionPolling() this.loading = false this.showSyncTaskRunning = false + this.taskPosition = null this.isNotifyEnabled = false this.task.status = TASK_STATUS_STRING.unknown this.task.output = null From cd6afb97b7ff0228615a4a8880b569d1d1a33f6b Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 27 Jul 2026 12:36:07 +0200 Subject: [PATCH 02/13] feat(position): display task's position in the chat input field placeholder Signed-off-by: Julien Veyssier --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 14 ++++++++++++++ src/components/ChattyLLM/InputArea.vue | 2 ++ 2 files changed, 16 insertions(+) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 4225308a..8401c575 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -259,6 +259,7 @@ import { SHAPE_TYPE_NAMES, TASK_STATUS_INT } from '../../constants.js' import ICAL from 'ical.js' import formatRecurrenceRule from './recurrenceRule.js' import { getLanguage } from '@nextcloud/l10n' +import { getTaskPosition } from '../../assistant.js' import navAutoCollapse from '../../mixins/navAutoCollapse.js' @@ -343,6 +344,7 @@ export default { newSession: false, messageDelete: false, sessionDelete: false, + taskPosition: null, }, msgCursor: 0, msgLimit: 20, @@ -443,6 +445,7 @@ export default { this.allMessagesLoaded = false this.loading.llmGeneration = false this.loading.llmRunning = false + this.loading.taskPosition = null this.loading.titleGeneration = false this.streamingMessage = null this.chatContent = '' @@ -562,6 +565,7 @@ export default { } finally { this.loading.llmGeneration = false this.loading.llmRunning = false + this.loading.taskPosition = null this.loading.titleGeneration = false if (isAssignment) { this.pollCheckSessionTimeout = setTimeout(() => { this.checkSession(sessionId, isAssignment) }, 5000) @@ -931,6 +935,7 @@ export default { this.slowPickup = false this.loading.llmGeneration = true this.loading.llmRunning = false + this.loading.taskPosition = null this.userScrolled = false const params = { sessionId, @@ -956,6 +961,7 @@ export default { } finally { this.loading.llmGeneration = false this.loading.llmRunning = false + this.loading.taskPosition = null this.streamingMessage = null this.userScrolled = false } @@ -966,6 +972,7 @@ export default { const sessionId = this.active.id this.loading.llmGeneration = true this.loading.llmRunning = false + this.loading.taskPosition = null this.userScrolled = false const regenerationResponse = await axios.get(getChatURL('/regenerate'), { params: { messageId, sessionId } }) const regenerationResponseData = regenerationResponse.data @@ -984,6 +991,7 @@ export default { } finally { this.loading.llmGeneration = false this.loading.llmRunning = false + this.loading.taskPosition = null this.streamingMessage = null this.userScrolled = false } @@ -1064,6 +1072,12 @@ export default { this.slowPickup = error.response.data.slow_pickup if (error.response.data.task_status === TASK_STATUS_INT.running) { this.loading.llmRunning = true + } else if (error.response.data.task_status === TASK_STATUS_INT.scheduled) { + getTaskPosition(taskId).then(response => { + const taskPosition = response.data?.ocs?.data + this.loading.taskPosition = taskPosition + console.debug('Task position:', taskPosition) + }) } if (!hasPush && typeof error.response.data.task_output !== 'undefined' && error.response.data.task_output !== null) { this.updateStreamingMessage(error.response.data.task_output || {}, sessionId) diff --git a/src/components/ChattyLLM/InputArea.vue b/src/components/ChattyLLM/InputArea.vue index baa88850..d7f800fc 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -141,6 +141,7 @@ export default { newSession: false, messageDelete: false, sessionDelete: false, + taskPosition: null, }), }, }, @@ -190,6 +191,7 @@ export default { ? this.loading.llmRunning ? this.thinkingText : this.scheduledText + + (this.loading.taskPosition ? ' ' + t('assistant', 'Task position: {position}', { position: this.loading.taskPosition }) : '') : this.placeholderText }, }, From 65335f3847bb4197ff06a0792c6a517136a0206b Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 17 Aug 2026 10:59:34 +0200 Subject: [PATCH 03/13] fix: start polling task queue position after setting window.assistantPollPositionTimerId so pollPositionOnce does not exit and reject the promise Signed-off-by: Julien Veyssier --- src/assistant.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assistant.js b/src/assistant.js index 8b6aa4dd..b370c1e3 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -461,9 +461,9 @@ export async function pollTaskPosition(taskId, obj, callback = updateTaskPositio reject(new Error('pollTaskPosition request failed')) }) } + window.assistantPollPositionTimerId = setInterval(pollPositionOnce, 5000) // start polling immediately pollPositionOnce() - window.assistantPollPositionTimerId = setInterval(pollPositionOnce, 5000) }) } From f9ddefd1dbfc4f381ac01bb65f2c92a147759c83 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 17 Aug 2026 11:03:16 +0200 Subject: [PATCH 04/13] enh: add console error log when failing to get a task position in the chat UI Signed-off-by: Julien Veyssier --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 8401c575..68e3b323 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -1073,11 +1073,15 @@ export default { if (error.response.data.task_status === TASK_STATUS_INT.running) { this.loading.llmRunning = true } else if (error.response.data.task_status === TASK_STATUS_INT.scheduled) { - getTaskPosition(taskId).then(response => { - const taskPosition = response.data?.ocs?.data - this.loading.taskPosition = taskPosition - console.debug('Task position:', taskPosition) - }) + getTaskPosition(taskId) + .then(response => { + const taskPosition = response.data?.ocs?.data + this.loading.taskPosition = taskPosition + console.debug('Task position:', taskPosition) + }) + .catch(error => { + console.error('Failed to get task position', error) + }) } if (!hasPush && typeof error.response.data.task_output !== 'undefined' && error.response.data.task_output !== null) { this.updateStreamingMessage(error.response.data.task_output || {}, sessionId) From 4e1f12dc9c260660c5e6518a1f617d5a51891dad Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 17 Aug 2026 11:18:47 +0200 Subject: [PATCH 05/13] enh: stop polling task position when we stop polling the task (task finished or failed) Signed-off-by: Julien Veyssier --- src/assistant.js | 9 +++++++++ src/views/AssistantPage.vue | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/src/assistant.js b/src/assistant.js index b370c1e3..ebcc162b 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -233,6 +233,7 @@ export async function openAssistantForm({ view.loading = false view.showSyncTaskRunning = false view.taskPosition = null + cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error.message) @@ -240,6 +241,7 @@ export async function openAssistantForm({ view.loading = false view.showSyncTaskRunning = false view.taskPosition = null + cancelTaskPositionPolling() view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -342,6 +344,7 @@ export async function openAssistantForm({ view.loading = false view.showSyncTaskRunning = false view.taskPosition = null + cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) @@ -349,6 +352,7 @@ export async function openAssistantForm({ view.loading = false view.showSyncTaskRunning = false view.taskPosition = null + cancelTaskPositionPolling() view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -461,6 +465,7 @@ export async function pollTaskPosition(taskId, obj, callback = updateTaskPositio reject(new Error('pollTaskPosition request failed')) }) } + cancelTaskPositionPolling() window.assistantPollPositionTimerId = setInterval(pollPositionOnce, 5000) // start polling immediately pollPositionOnce() @@ -843,6 +848,7 @@ export async function openAssistantTask( view.loading = false view.showSyncTaskRunning = false view.taskPosition = null + cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) @@ -851,6 +857,7 @@ export async function openAssistantTask( view.loading = false view.showSyncTaskRunning = false view.taskPosition = null + cancelTaskPositionPolling() view.isNotifyEnabled = false view.selectedTaskId = null lastTask = null @@ -948,6 +955,7 @@ export async function openAssistantTask( view.loading = false view.showSyncTaskRunning = false view.taskPosition = null + cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) @@ -955,6 +963,7 @@ export async function openAssistantTask( view.loading = false view.showSyncTaskRunning = false view.taskPosition = null + cancelTaskPositionPolling() view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index 36d9964e..2a6be473 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -198,6 +198,8 @@ export default { this.loading = false this.showSyncTaskRunning = false this.taskPosition = null + // the position polling would stop on the next request but why not stopping it right now + cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) @@ -205,6 +207,7 @@ export default { this.loading = false this.showSyncTaskRunning = false this.taskPosition = null + cancelTaskPositionPolling() this.isNotifyEnabled = false this.task.status = TASK_STATUS_STRING.unknown this.task.output = null @@ -294,6 +297,7 @@ export default { this.loading = false this.showSyncTaskRunning = false this.taskPosition = null + cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('Assistant poll error', error) @@ -301,6 +305,7 @@ export default { this.loading = false this.showSyncTaskRunning = false this.taskPosition = null + cancelTaskPositionPolling() this.isNotifyEnabled = false this.task.status = TASK_STATUS_STRING.unknown this.task.output = null From 07ee9ae7523f09ce5125f4010b948a1d31179b91 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 12:24:39 +0200 Subject: [PATCH 06/13] fix: fix position polling cancellation to properly reject promises and cancel on all error paths Signed-off-by: Julien Veyssier --- src/assistant.js | 94 ++++++++++++++++---------- src/components/ChattyLLM/InputArea.vue | 4 +- src/views/AssistantPage.vue | 16 ++--- 3 files changed, 70 insertions(+), 44 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index ebcc162b..2ea69a4f 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -10,8 +10,12 @@ import PrimeVue from 'primevue/config' import Aura from '@primeuix/themes/aura' import { listen } from '@nextcloud/notify_push' +window.assistantPollAbortController = null window.assistantPollTimerId = null +window.assistantPollTaskId = null window.assistantPollPositionTimerId = null +window.assistantPollPositionTaskId = null +window.assistantPollPositionAbortController = null listen('taskprocessing:task_update', (type, body) => { console.debug('[assistant] received task update push notification', type, body) @@ -200,9 +204,9 @@ export async function openAssistantForm({ console.debug('[assistant] HAS PUSH', hasPush) pollTaskPosition(task.id, view).then(() => { - console.debug('[assistant] pollTaskPosition finished') + console.debug('[assistant] pollTaskPosition: the task is not scheduled anymore ', task.id) }).catch(error => { - console.debug('[assistant] pollPosition error', error.message) + console.debug('[assistant] pollPosition error', task.id, error.message) }) // no need to update the task output with polling if we have push notifications pollTask(task.id, view, !hasPush).then(finishedTask => { @@ -237,11 +241,11 @@ export async function openAssistantForm({ emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error.message) + view.taskPosition = null + cancelTaskPositionPolling() if (error.message === 'task-not-found') { view.loading = false view.showSyncTaskRunning = false - view.taskPosition = null - cancelTaskPositionPolling() view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -316,9 +320,9 @@ export async function openAssistantForm({ console.debug('[assistant] HAS PUSH', hasPush) pollTaskPosition(updatedTask.id, view).then(() => { - console.debug('[assistant] pollTaskPosition finished') + console.debug('[assistant] pollTaskPosition: the task is not scheduled anymore', updatedTask.id) }).catch(error => { - console.debug('[assistant] pollPosition error', error.message) + console.debug('[assistant] pollPosition error', updatedTask.id, error.message) }) pollTask(updatedTask.id, view, !hasPush).then(finishedTask => { console.debug('pollTask.then', finishedTask) @@ -348,11 +352,11 @@ export async function openAssistantForm({ emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) + view.taskPosition = null + cancelTaskPositionPolling() if (error.message === 'task-not-found') { view.loading = false view.showSyncTaskRunning = false - view.taskPosition = null - cancelTaskPositionPolling() view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null @@ -431,19 +435,20 @@ function updateTaskPosition(position, object) { * * @param {number} taskId the task ID * @param {object} obj the object to update - * @param {Function} callback the function to call to update the object - * @return {Promise<*>} + * @param {(position: number, obj: object) => void} callback the function to call to update the object + * @return {Promise} */ export async function pollTaskPosition(taskId, obj, callback = updateTaskPosition) { + const { isCancel } = await import('@nextcloud/axios') return new Promise((resolve, reject) => { const pollPositionOnce = () => { - if (window.assistantPollPositionTimerId === null) { + if (window.assistantPollPositionTaskId !== taskId) { reject(new Error('pollTaskPosition cancelled')) return } - getTaskPosition(taskId).then(response => { + getTaskPosition(taskId, window.assistantPollPositionAbortController.signal).then(response => { const taskPosition = response.data?.ocs?.data - if (window.assistantPollPositionTimerId === null) { + if (window.assistantPollPositionTaskId !== taskId) { reject(new Error('pollTaskPosition cancelled')) return } @@ -451,9 +456,17 @@ export async function pollTaskPosition(taskId, obj, callback = updateTaskPositio callback(taskPosition, obj) } }).catch(error => { + if (window.assistantPollPositionTaskId === taskId) { + clearInterval(window.assistantPollPositionTimerId) + window.assistantPollPositionTimerId = null + window.assistantPollPositionTaskId = null + } + if (isCancel(error)) { + console.debug('[assistant] pollPosition request cancelled', error) + reject(new Error('pollTaskPosition request cancelled')) + return + } console.debug('[assistant] pollPosition request failed', error) - clearInterval(window.assistantPollPositionTimerId) - window.assistantPollPositionTimerId = null if (error.status === 404) { reject(new Error('task-not-found')) return @@ -466,6 +479,8 @@ export async function pollTaskPosition(taskId, obj, callback = updateTaskPositio }) } cancelTaskPositionPolling() + window.assistantPollPositionTaskId = taskId + window.assistantPollPositionAbortController = new AbortController() window.assistantPollPositionTimerId = setInterval(pollPositionOnce, 5000) // start polling immediately pollPositionOnce() @@ -484,9 +499,9 @@ export async function pollTaskPosition(taskId, obj, callback = updateTaskPositio export async function pollTask(taskId, obj, updateOutput = true, callback = updateTask) { return new Promise((resolve, reject) => { const pollOnce = () => { - getTask(taskId).then(response => { + getTask(taskId, window.assistantPollAbortController.signal).then(response => { const task = response.data?.ocs?.data?.task - if (window.assistantPollTimerId === null) { + if (window.assistantPollTaskId !== taskId) { reject(new Error('pollTask cancelled')) return } @@ -502,14 +517,20 @@ export async function pollTask(taskId, obj, updateOutput = true, callback = upda }).catch(error => { console.debug('[assistant] poll request failed', error) if (error.status === 404) { - clearInterval(window.assistantPollTimerId) - window.assistantPollTimerId = null + if (window.assistantPollTaskId === taskId) { + clearInterval(window.assistantPollTimerId) + window.assistantPollTimerId = null + window.assistantPollTaskId = null + } reject(new Error('task-not-found')) return } reject(new Error('pollTask request failed')) }) } + cancelTaskPolling() + window.assistantPollTaskId = taskId + window.assistantPollAbortController = new AbortController() // start polling immediately // pollOnce() window.assistantPollTimerId = setInterval(pollOnce, 2000) @@ -517,29 +538,33 @@ export async function pollTask(taskId, obj, updateOutput = true, callback = upda } export async function cancelTaskPolling() { - window.assistantAbortController?.abort() + window.assistantPollAbortController?.abort() clearInterval(window.assistantPollTimerId) window.assistantPollTimerId = null + window.assistantPollTaskId = null } export async function cancelTaskPositionPolling() { + window.assistantPollPositionAbortController?.abort() clearInterval(window.assistantPollPositionTimerId) window.assistantPollPositionTimerId = null + window.assistantPollPositionTaskId = null } -export async function getTask(taskId) { - window.assistantAbortController = new AbortController() +export async function getTask(taskId, signal = null) { const { default: axios } = await import('@nextcloud/axios') const { generateOcsUrl } = await import('@nextcloud/router') const url = generateOcsUrl('taskprocessing/task/{taskId}', { taskId }) - return axios.get(url, { signal: window.assistantAbortController.signal }) + const config = signal ? { signal } : {} + return axios.get(url, config) } -export async function getTaskPosition(taskId) { +export async function getTaskPosition(taskId, signal = null) { const { default: axios } = await import('@nextcloud/axios') const { generateOcsUrl } = await import('@nextcloud/router') const url = generateOcsUrl('taskprocessing/tasks/{taskId}/queue_position', { taskId }) - return axios.get(url, {}) + const config = signal ? { signal } : {} + return axios.get(url, config) } export async function getNotifyReady(taskId) { @@ -575,7 +600,6 @@ export async function cancelTask(taskId) { * @return {Promise} */ export async function scheduleTask(appId, customId, taskType, inputs) { - window.assistantAbortController = new AbortController() const { default: axios } = await import('@nextcloud/axios') const { generateOcsUrl } = await import('@nextcloud/router') if (taskType === 'core:text2text:translate') { @@ -589,7 +613,7 @@ export async function scheduleTask(appId, customId, taskType, inputs) { customId, preferStreaming: true, } - return axios.post(url, params, { signal: window.assistantAbortController.signal }) + return axios.post(url, params) } export async function saveLastSelectedTaskType(taskType) { @@ -822,9 +846,9 @@ export async function openAssistantTask( console.debug('[assistant] HAS PUSH', hasPush) pollTaskPosition(task.id, view).then(() => { - console.debug('[assistant] pollTaskPosition finished') + console.debug('[assistant] pollTaskPosition: the task is not scheduled anymore', task.id) }).catch(error => { - console.debug('[assistant] pollPosition error', error.message) + console.debug('[assistant] pollPosition error', task.id, error.message) }) pollTask(task.id, view, !hasPush).then(finishedTask => { if (finishedTask.status === TASK_STATUS_STRING.successful) { @@ -853,11 +877,11 @@ export async function openAssistantTask( }).catch(error => { console.debug('[assistant] poll error', error) view.outputs = null + view.taskPosition = null + cancelTaskPositionPolling() if (error.message === 'task-not-found') { view.loading = false view.showSyncTaskRunning = false - view.taskPosition = null - cancelTaskPositionPolling() view.isNotifyEnabled = false view.selectedTaskId = null lastTask = null @@ -927,9 +951,9 @@ export async function openAssistantTask( const hasPush = listenToTaskNotifications(task.id) pollTaskPosition(updatedTask.id, view).then(() => { - console.debug('[assistant] pollTaskPosition finished') + console.debug('[assistant] pollTaskPosition: the task is not scheduled anymore', updatedTask.id) }).catch(error => { - console.debug('[assistant] pollPosition error', error.message) + console.debug('[assistant] pollPosition error', updatedTask.id, error.message) }) pollTask(updatedTask.id, view, !hasPush).then(finishedTask => { console.debug('pollTask.then', finishedTask) @@ -959,11 +983,11 @@ export async function openAssistantTask( emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) + view.taskPosition = null + cancelTaskPositionPolling() if (error.message === 'task-not-found') { view.loading = false view.showSyncTaskRunning = false - view.taskPosition = null - cancelTaskPositionPolling() view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null diff --git a/src/components/ChattyLLM/InputArea.vue b/src/components/ChattyLLM/InputArea.vue index d7f800fc..53e97633 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -191,7 +191,9 @@ export default { ? this.loading.llmRunning ? this.thinkingText : this.scheduledText - + (this.loading.taskPosition ? ' ' + t('assistant', 'Task position: {position}', { position: this.loading.taskPosition }) : '') + + ((this.loading.taskPosition !== null && this.loading.taskPosition !== undefined) + ? ' ' + t('assistant', 'Task position: {position}', { position: this.loading.taskPosition }) + : '') : this.placeholderText }, }, diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index 2a6be473..e3c3dca3 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -183,9 +183,9 @@ export default { console.debug('[assistant] HAS PUSH', hasPush) pollTaskPosition(task.id, this).then(() => { - console.debug('[assistant] pollTaskPosition finished') + console.debug('[assistant] pollTaskPosition finished', task.id) }).catch(error => { - console.debug('[assistant] pollPosition error', error.message) + console.debug('[assistant] pollPosition error', task.id, error.message) }) pollTask(task.id, this, !hasPush, this.updateTask).then(finishedTask => { if (finishedTask.status === TASK_STATUS_STRING.successful) { @@ -203,11 +203,11 @@ export default { emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('[assistant] poll error', error) + this.taskPosition = null + cancelTaskPositionPolling() if (error.message === 'task-not-found') { this.loading = false this.showSyncTaskRunning = false - this.taskPosition = null - cancelTaskPositionPolling() this.isNotifyEnabled = false this.task.status = TASK_STATUS_STRING.unknown this.task.output = null @@ -279,9 +279,9 @@ export default { const hasPush = this.listenToTaskNotifications(task.id) pollTaskPosition(updatedTask.id, this).then(() => { - console.debug('[assistant] pollTaskPosition finished') + console.debug('[assistant] pollTaskPosition finished', updatedTask.id) }).catch(error => { - console.debug('[assistant] pollPosition error', error.message) + console.debug('[assistant] pollPosition error', updatedTask.id, error.message) }) pollTask(updatedTask.id, this, !hasPush, this.updateTask).then(finishedTask => { console.debug('pollTask.then', finishedTask) @@ -301,11 +301,11 @@ export default { emit('assistant:task:updated', finishedTask) }).catch(error => { console.debug('Assistant poll error', error) + this.taskPosition = null + cancelTaskPositionPolling() if (error.message === 'task-not-found') { this.loading = false this.showSyncTaskRunning = false - this.taskPosition = null - cancelTaskPositionPolling() this.isNotifyEnabled = false this.task.status = TASK_STATUS_STRING.unknown this.task.output = null From a0af7bb837780a885b573155dfbc9afcb3a8774e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 18:20:48 +0200 Subject: [PATCH 07/13] fix: guard against stale getTask() and getNotifyReady() calls Signed-off-by: Julien Veyssier --- src/assistant.js | 14 ++++++++++++++ src/views/AssistantPage.vue | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/src/assistant.js b/src/assistant.js index 2ea69a4f..6729666e 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -290,6 +290,10 @@ export async function openAssistantForm({ if ([TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(task?.status)) { getTask(task.id).then(response => { + if (task.id !== view.selectedTaskId) { + console.debug('[assistant] ignoring stale getTask response for task', task.id, 'selected is', view.selectedTaskId) + return + } const updatedTask = response.data?.ocs?.data?.task if (![TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(updatedTask?.status)) { @@ -303,6 +307,9 @@ export async function openAssistantForm({ } getNotifyReady(task.id).then(response => { + if (task.id !== view.selectedTaskId) { + return + } view.isNotifyEnabled = !!response.data?.ocs?.data?.id }).catch(error => { console.error('[assistant] get task notification status error', error) @@ -922,6 +929,10 @@ export async function openAssistantTask( if ([TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(task?.status)) { getTask(task.id).then(response => { + if (task.id !== view.selectedTaskId) { + console.debug('[assistant] ignoring stale getTask response for task', task.id, 'selected is', view.selectedTaskId) + return + } const updatedTask = response.data?.ocs?.data?.task if (![TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(updatedTask?.status)) { @@ -935,6 +946,9 @@ export async function openAssistantTask( } getNotifyReady(task.id).then(response => { + if (task.id !== view.selectedTaskId) { + return + } view.isNotifyEnabled = !!response.data?.ocs?.data?.id }).catch(error => { console.error('[assistant] get task notification status error', error) diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index e3c3dca3..d246235f 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -258,6 +258,10 @@ export default { if ([TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(task?.status)) { getTask(task.id).then(response => { + if (task.id !== this.task.id) { + console.debug('[assistant] ignoring stale getTask response for task', task.id, 'selected is', this.task.id) + return + } const updatedTask = response.data?.ocs?.data?.task if (![TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(updatedTask?.status)) { From 03563d4dc9f22e6dd2f6a3fb87ec601f5be1637c Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 18:32:23 +0200 Subject: [PATCH 08/13] fix: bring back schedule request cancelling, refactor it to clarify Signed-off-by: Julien Veyssier --- src/assistant.js | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index 6729666e..7694e6bf 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -16,6 +16,7 @@ window.assistantPollTaskId = null window.assistantPollPositionTimerId = null window.assistantPollPositionTaskId = null window.assistantPollPositionAbortController = null +window.assistantSchedulingAbortController = null listen('taskprocessing:task_update', (type, body) => { console.debug('[assistant] received task update push notification', type, body) @@ -172,6 +173,7 @@ export async function openAssistantForm({ } modalMountPoint.addEventListener('cancel', () => { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() app.unmount() @@ -191,15 +193,16 @@ export async function openAssistantForm({ view.outputs = null view.selectedTaskTypeId = taskTypeId - scheduleTask(appId, newTaskCustomId, taskTypeId, inputs) + window.assistantSchedulingAbortController = new AbortController() + scheduleTask(appId, newTaskCustomId, taskTypeId, inputs, window.assistantSchedulingAbortController.signal) .then((response) => { + cancelScheduling() const task = response.data?.ocs?.data?.task lastTask = task view.selectedTaskId = lastTask?.id view.expectedRuntime = (lastTask?.completionExpectedAt - lastTask?.scheduledAt) || null view.startedAt = lastTask?.startedAt || null view.completionExpectedAt = lastTask?.completionExpectedAt || null - const hasPush = listenToTaskNotifications(task.id) console.debug('[assistant] HAS PUSH', hasPush) @@ -255,6 +258,7 @@ export async function openAssistantForm({ }) }) .catch(error => { + cancelScheduling() view.loading = false view.showSyncTaskRunning = false view.taskPosition = null @@ -267,6 +271,7 @@ export async function openAssistantForm({ syncSubmit(data.detail.inputs, data.detail.selectedTaskTypeId, customId || identifier) }) modalMountPoint.addEventListener('try-again', (data) => { + cancelScheduling() const task = data.detail console.debug('[assistant] try again', task) syncSubmit(task.input, task.type) @@ -274,6 +279,7 @@ export async function openAssistantForm({ modalMountPoint.addEventListener('load-task', (data) => { const task = data.detail console.debug('[assistant] loading task', task) + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() view.showSyncTaskRunning = false @@ -378,6 +384,7 @@ export async function openAssistantForm({ }) modalMountPoint.addEventListener('new-task', () => { console.debug('[assistant] new task') + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() view.loading = false @@ -395,6 +402,7 @@ export async function openAssistantForm({ }) }) modalMountPoint.addEventListener('cancel-task', () => { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() setNotifyReady(lastTask.id, false) @@ -558,6 +566,11 @@ export async function cancelTaskPositionPolling() { window.assistantPollPositionTaskId = null } +export async function cancelScheduling() { + window.assistantSchedulingAbortController?.abort() + window.assistantSchedulingAbortController = null +} + export async function getTask(taskId, signal = null) { const { default: axios } = await import('@nextcloud/axios') const { generateOcsUrl } = await import('@nextcloud/router') @@ -604,9 +617,10 @@ export async function cancelTask(taskId) { * @param {string} customId the task custom ID * @param {string} taskType the task type class * @param {Array} inputs the task input texts as an array + * @param {AbortSignal} signal optional abort signal for cancellation * @return {Promise} */ -export async function scheduleTask(appId, customId, taskType, inputs) { +export async function scheduleTask(appId, customId, taskType, inputs, signal = null) { const { default: axios } = await import('@nextcloud/axios') const { generateOcsUrl } = await import('@nextcloud/router') if (taskType === 'core:text2text:translate') { @@ -620,7 +634,8 @@ export async function scheduleTask(appId, customId, taskType, inputs) { customId, preferStreaming: true, } - return axios.post(url, params) + const config = signal ? { signal } : {} + return axios.post(url, params, config) } export async function saveLastSelectedTaskType(taskType) { @@ -809,17 +824,21 @@ export async function openAssistantTask( } modalMountPoint.addEventListener('cancel', () => { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() app.unmount() OCA.Assistant.isAssistantDialogOpen = false }) modalMountPoint.addEventListener('submit', (data) => { - scheduleTask(task.appId, task.identifier ?? '', data.detail.selectedTaskTypeId, data.detail.inputs) + window.assistantSchedulingAbortController = new AbortController() + scheduleTask(task.appId, task.identifier ?? '', data.detail.selectedTaskTypeId, data.detail.inputs, window.assistantSchedulingAbortController.signal) .then((response) => { + cancelScheduling() console.debug('scheduled task', response.data?.ocs?.data?.task) }) .catch(error => { + cancelScheduling() app.unmount() OCA.Assistant.isAssistantDialogOpen = false console.error('Assistant scheduling error', error) @@ -841,8 +860,10 @@ export async function openAssistantTask( view.outputs = null view.selectedTaskTypeId = taskTypeId - scheduleTask('assistant', newTaskCustomId, taskTypeId, inputs) + window.assistantSchedulingAbortController = new AbortController() + scheduleTask('assistant', newTaskCustomId, taskTypeId, inputs, window.assistantSchedulingAbortController.signal) .then((response) => { + cancelScheduling() const task = response.data?.ocs?.data?.task lastTask = task view.selectedTaskId = lastTask?.id @@ -897,6 +918,7 @@ export async function openAssistantTask( }) }) .catch(error => { + cancelScheduling() view.loading = false view.showSyncTaskRunning = false view.taskPosition = null @@ -908,11 +930,13 @@ export async function openAssistantTask( syncSubmit(data.detail.inputs, data.detail.selectedTaskTypeId, task.identifier ?? '') }) modalMountPoint.addEventListener('try-again', (data) => { + cancelScheduling() const task = data.detail syncSubmit(task.input, task.type) }) modalMountPoint.addEventListener('load-task', (data) => { const task = data.detail + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() view.showSyncTaskRunning = false @@ -1016,6 +1040,7 @@ export async function openAssistantTask( }) modalMountPoint.addEventListener('new-task', () => { console.debug('[assistant] new task') + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() view.loading = false @@ -1033,6 +1058,7 @@ export async function openAssistantTask( }) }) modalMountPoint.addEventListener('cancel-task', () => { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() setNotifyReady(lastTask.id, false) From 95058d53b25545f6d55e0440ea627bd86b9cf62d Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 18:43:26 +0200 Subject: [PATCH 09/13] fix: also deal with schedule task canceling in AssistantPage.vue Signed-off-by: Julien Veyssier --- src/views/AssistantPage.vue | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index d246235f..f0f69c4e 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -45,6 +45,7 @@ import { emit } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { listen } from '@nextcloud/notify_push' import { + cancelScheduling, cancelTask, cancelTaskPolling, cancelTaskPositionPolling, @@ -117,6 +118,7 @@ export default { } }, onCancel() { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() if (this.task?.id) { @@ -170,8 +172,10 @@ export default { this.task.input = inputs this.task.output = null this.task.type = taskTypeId - scheduleTask('assistant', this.task.identifier, taskTypeId, inputs) + window.assistantSchedulingAbortController = new AbortController() + scheduleTask('assistant', this.task.identifier, taskTypeId, inputs, window.assistantSchedulingAbortController.signal) .then((response) => { + cancelScheduling() console.debug('Assistant SYNC result', response.data?.ocs?.data) const task = response.data?.ocs?.data?.task this.task.id = task.id @@ -217,6 +221,7 @@ export default { }) }) .catch(error => { + cancelScheduling() this.loading = false this.showSyncTaskRunning = false this.taskPosition = null @@ -241,9 +246,11 @@ export default { this.syncSubmit(data.inputs, data.selectedTaskTypeId, this.task.identifier) }, onTryAgain(task) { + cancelScheduling() this.syncSubmit(task.input, task.type) }, onLoadTask(task) { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() this.showSyncTaskRunning = false @@ -323,6 +330,7 @@ export default { } }, onNewTask() { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() this.loading = false From 2bd7979588a34765af468ec8123c05a1a183c9ac Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 18:47:30 +0200 Subject: [PATCH 10/13] fix: race condition in schedule task cancelling logic Signed-off-by: Julien Veyssier --- src/assistant.js | 48 +++++++++++++++++++++++++++---------- src/views/AssistantPage.vue | 16 +++++++++---- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index 7694e6bf..f21aafeb 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -193,10 +193,13 @@ export async function openAssistantForm({ view.outputs = null view.selectedTaskTypeId = taskTypeId - window.assistantSchedulingAbortController = new AbortController() - scheduleTask(appId, newTaskCustomId, taskTypeId, inputs, window.assistantSchedulingAbortController.signal) + const controller = new AbortController() + window.assistantSchedulingAbortController = controller + scheduleTask(appId, newTaskCustomId, taskTypeId, inputs, controller.signal) .then((response) => { - cancelScheduling() + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } const task = response.data?.ocs?.data?.task lastTask = task view.selectedTaskId = lastTask?.id @@ -258,7 +261,12 @@ export async function openAssistantForm({ }) }) .catch(error => { - cancelScheduling() + if (controller.signal.aborted) { + return + } + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } view.loading = false view.showSyncTaskRunning = false view.taskPosition = null @@ -831,14 +839,22 @@ export async function openAssistantTask( OCA.Assistant.isAssistantDialogOpen = false }) modalMountPoint.addEventListener('submit', (data) => { - window.assistantSchedulingAbortController = new AbortController() - scheduleTask(task.appId, task.identifier ?? '', data.detail.selectedTaskTypeId, data.detail.inputs, window.assistantSchedulingAbortController.signal) + const controller = new AbortController() + window.assistantSchedulingAbortController = controller + scheduleTask(task.appId, task.identifier ?? '', data.detail.selectedTaskTypeId, data.detail.inputs, controller.signal) .then((response) => { - cancelScheduling() + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } console.debug('scheduled task', response.data?.ocs?.data?.task) }) .catch(error => { - cancelScheduling() + if (controller.signal.aborted) { + return + } + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } app.unmount() OCA.Assistant.isAssistantDialogOpen = false console.error('Assistant scheduling error', error) @@ -860,10 +876,13 @@ export async function openAssistantTask( view.outputs = null view.selectedTaskTypeId = taskTypeId - window.assistantSchedulingAbortController = new AbortController() - scheduleTask('assistant', newTaskCustomId, taskTypeId, inputs, window.assistantSchedulingAbortController.signal) + const controller = new AbortController() + window.assistantSchedulingAbortController = controller + scheduleTask('assistant', newTaskCustomId, taskTypeId, inputs, controller.signal) .then((response) => { - cancelScheduling() + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } const task = response.data?.ocs?.data?.task lastTask = task view.selectedTaskId = lastTask?.id @@ -918,7 +937,12 @@ export async function openAssistantTask( }) }) .catch(error => { - cancelScheduling() + if (controller.signal.aborted) { + return + } + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } view.loading = false view.showSyncTaskRunning = false view.taskPosition = null diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index f0f69c4e..92dcea77 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -172,10 +172,13 @@ export default { this.task.input = inputs this.task.output = null this.task.type = taskTypeId - window.assistantSchedulingAbortController = new AbortController() - scheduleTask('assistant', this.task.identifier, taskTypeId, inputs, window.assistantSchedulingAbortController.signal) + const controller = new AbortController() + window.assistantSchedulingAbortController = controller + scheduleTask('assistant', this.task.identifier, taskTypeId, inputs, controller.signal) .then((response) => { - cancelScheduling() + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } console.debug('Assistant SYNC result', response.data?.ocs?.data) const task = response.data?.ocs?.data?.task this.task.id = task.id @@ -221,7 +224,12 @@ export default { }) }) .catch(error => { - cancelScheduling() + if (controller.signal.aborted) { + return + } + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } this.loading = false this.showSyncTaskRunning = false this.taskPosition = null From ee2b7097cba645afa9e8855cd2766f2433124f69 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 19:11:13 +0200 Subject: [PATCH 11/13] fix: clear interval when needed Signed-off-by: Julien Veyssier --- src/assistant.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index f21aafeb..42517bdb 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -479,26 +479,30 @@ export async function pollTaskPosition(taskId, obj, callback = updateTaskPositio callback(taskPosition, obj) } }).catch(error => { - if (window.assistantPollPositionTaskId === taskId) { - clearInterval(window.assistantPollPositionTimerId) - window.assistantPollPositionTimerId = null - window.assistantPollPositionTaskId = null + const stopPolling = () => { + if (window.assistantPollPositionTaskId === taskId) { + clearInterval(window.assistantPollPositionTimerId) + window.assistantPollPositionTimerId = null + window.assistantPollPositionTaskId = null + } } if (isCancel(error)) { + stopPolling() console.debug('[assistant] pollPosition request cancelled', error) reject(new Error('pollTaskPosition request cancelled')) return } console.debug('[assistant] pollPosition request failed', error) if (error.status === 404) { + stopPolling() reject(new Error('task-not-found')) return } else if (error.status === 412) { - // the task is not scheduled anymore + stopPolling() resolve() return } - reject(new Error('pollTaskPosition request failed')) + console.warn('[assistant] pollPosition temporary failure, will retry', error) }) } cancelTaskPositionPolling() @@ -548,7 +552,7 @@ export async function pollTask(taskId, obj, updateOutput = true, callback = upda reject(new Error('task-not-found')) return } - reject(new Error('pollTask request failed')) + console.warn('[assistant] poll temporary failure, will retry', error) }) } cancelTaskPolling() From c16c72e3e606d22cf54956888489717ce98745a9 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 19:14:47 +0200 Subject: [PATCH 12/13] fix: in chat UI, check if the received position is for the currently selected session Signed-off-by: Julien Veyssier --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 68e3b323..dc238c9e 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -1075,6 +1075,9 @@ export default { } else if (error.response.data.task_status === TASK_STATUS_INT.scheduled) { getTaskPosition(taskId) .then(response => { + if (sessionId !== this.active?.id) { + return + } const taskPosition = response.data?.ocs?.data this.loading.taskPosition = taskPosition console.debug('Task position:', taskPosition) From ca3de9d1e1b53ccda232f0809feb87d8217e1289 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 19:23:00 +0200 Subject: [PATCH 13/13] fix one more race condition Signed-off-by: Julien Veyssier --- src/assistant.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index 42517bdb..32d0ca2e 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -465,13 +465,13 @@ export async function pollTaskPosition(taskId, obj, callback = updateTaskPositio const { isCancel } = await import('@nextcloud/axios') return new Promise((resolve, reject) => { const pollPositionOnce = () => { - if (window.assistantPollPositionTaskId !== taskId) { + if (window.assistantPollPositionTaskId !== taskId || window.assistantPollPositionTimerId === null) { reject(new Error('pollTaskPosition cancelled')) return } getTaskPosition(taskId, window.assistantPollPositionAbortController.signal).then(response => { const taskPosition = response.data?.ocs?.data - if (window.assistantPollPositionTaskId !== taskId) { + if (window.assistantPollPositionTaskId !== taskId || window.assistantPollPositionTimerId === null) { reject(new Error('pollTaskPosition cancelled')) return } @@ -528,7 +528,7 @@ export async function pollTask(taskId, obj, updateOutput = true, callback = upda const pollOnce = () => { getTask(taskId, window.assistantPollAbortController.signal).then(response => { const task = response.data?.ocs?.data?.task - if (window.assistantPollTaskId !== taskId) { + if (window.assistantPollTaskId !== taskId || window.assistantPollTimerId === null) { reject(new Error('pollTask cancelled')) return }