From 834f574a72e8f5e6edd3f391b93e5511419a1ff2 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 23 Jul 2026 13:39:35 +0200 Subject: [PATCH 1/8] 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 af13e2060eedcda783356dc4a84ed10fbb966a50 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 27 Jul 2026 12:36:07 +0200 Subject: [PATCH 2/8] 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 6ae0a04458abbd3a3d2cae175ce940e6fd4e7a74 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 17 Aug 2026 10:59:34 +0200 Subject: [PATCH 3/8] 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 410d89f77eebb730c4cf30dd7134beb89e49e48f Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 17 Aug 2026 11:03:16 +0200 Subject: [PATCH 4/8] 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 9786ac8796326ee06eb314f5bb4b461356d43c38 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 17 Aug 2026 11:18:47 +0200 Subject: [PATCH 5/8] 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 9406a85325c3af8765546f1006a5430f291a976d Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 12:24:39 +0200 Subject: [PATCH 6/8] 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 71a0dfba5882ae1b0fa99adc6a45712b92046620 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 28 Aug 2026 18:20:48 +0200 Subject: [PATCH 7/8] fix: refactor polling loops to fix race conditions and avoid stale request to make a late change to an unrelated context Signed-off-by: Julien Veyssier --- src/assistant.js | 314 +++++++++++++++--- .../ChattyLLM/ChattyLLMInputForm.vue | 3 + src/views/AssistantPage.vue | 37 ++- 3 files changed, 303 insertions(+), 51 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index 2ea69a4f..339758d9 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -10,12 +10,22 @@ import PrimeVue from 'primevue/config' import Aura from '@primeuix/themes/aura' import { listen } from '@nextcloud/notify_push' +export class TaskPollCancelledError extends Error { + constructor(msg = 'pollTask cancelled') { + super(msg) + this.name = 'TaskPollCancelledError' + } +} + window.assistantPollAbortController = null window.assistantPollTimerId = null +window.assistantPollRetryTimerId = null window.assistantPollTaskId = null window.assistantPollPositionTimerId = null +window.assistantPollPositionRetryTimerId = 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 +182,7 @@ export async function openAssistantForm({ } modalMountPoint.addEventListener('cancel', () => { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() app.unmount() @@ -179,6 +190,7 @@ export async function openAssistantForm({ reject(new Error('User cancellation')) }) const syncSubmit = (inputs, taskTypeId, newTaskCustomId = '') => { + cancelScheduling() view.loading = true view.showSyncTaskRunning = true view.taskPosition = null @@ -191,21 +203,29 @@ export async function openAssistantForm({ view.outputs = null view.selectedTaskTypeId = taskTypeId - scheduleTask(appId, newTaskCustomId, taskTypeId, inputs) + const controller = new AbortController() + window.assistantSchedulingAbortController = controller + scheduleTask(appId, newTaskCustomId, taskTypeId, inputs, controller.signal) .then((response) => { + if (window.assistantSchedulingAbortController !== controller) { + return + } + 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) pollTaskPosition(task.id, view).then(() => { console.debug('[assistant] pollTaskPosition: the task is not scheduled anymore ', task.id) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] pollPosition error', task.id, error.message) }) // no need to update the task output with polling if we have push notifications @@ -240,6 +260,9 @@ export async function openAssistantForm({ cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] poll error', error.message) view.taskPosition = null cancelTaskPositionPolling() @@ -255,6 +278,12 @@ export async function openAssistantForm({ }) }) .catch(error => { + if (controller.signal.aborted) { + return + } + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } view.loading = false view.showSyncTaskRunning = false view.taskPosition = null @@ -267,6 +296,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 +304,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 @@ -290,6 +321,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 +338,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) @@ -322,6 +360,9 @@ export async function openAssistantForm({ pollTaskPosition(updatedTask.id, view).then(() => { console.debug('[assistant] pollTaskPosition: the task is not scheduled anymore', updatedTask.id) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] pollPosition error', updatedTask.id, error.message) }) pollTask(updatedTask.id, view, !hasPush).then(finishedTask => { @@ -351,6 +392,9 @@ export async function openAssistantForm({ cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] poll error', error) view.taskPosition = null cancelTaskPositionPolling() @@ -371,6 +415,7 @@ export async function openAssistantForm({ }) modalMountPoint.addEventListener('new-task', () => { console.debug('[assistant] new task') + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() view.loading = false @@ -388,6 +433,7 @@ export async function openAssistantForm({ }) }) modalMountPoint.addEventListener('cancel-task', () => { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() setNotifyReady(lastTask.id, false) @@ -427,7 +473,8 @@ function updateTask(task, object, updateOutput = true) { } function updateTaskPosition(position, object) { - object.taskPosition = position + const n = Number(position) + object.taskPosition = Number.isFinite(n) ? n : null } /** @@ -441,48 +488,98 @@ function updateTaskPosition(position, object) { export async function pollTaskPosition(taskId, obj, callback = updateTaskPosition) { const { isCancel } = await import('@nextcloud/axios') return new Promise((resolve, reject) => { + cancelTaskPositionPolling() + window.assistantPollPositionTaskId = taskId + const abortController = new AbortController() + window.assistantPollPositionAbortController = abortController + let retryDelay = 5000 + let settled = false + + const cleanup = () => { + clearTimeout(window.assistantPollPositionTimerId) + clearTimeout(window.assistantPollPositionRetryTimerId) + window.assistantPollPositionTimerId = null + window.assistantPollPositionRetryTimerId = null + if (window.assistantPollPositionTaskId === taskId) { + window.assistantPollPositionTaskId = null + window.assistantPollPositionAbortController = null + } + } + + const safeReject = (err) => { + if (!settled) { + settled = true + cleanup() + reject(err) + } + } + + const safeResolve = (val) => { + if (!settled) { + settled = true + cleanup() + resolve(val) + } + } + + abortController.signal.addEventListener('abort', () => { + safeReject(new TaskPollCancelledError('pollTaskPosition aborted')) + }) + const pollPositionOnce = () => { if (window.assistantPollPositionTaskId !== taskId) { - reject(new Error('pollTaskPosition cancelled')) + safeReject(new TaskPollCancelledError('pollTaskPosition cancelled')) return } - getTaskPosition(taskId, window.assistantPollPositionAbortController.signal).then(response => { - const taskPosition = response.data?.ocs?.data + + getTaskPosition(taskId, abortController.signal).then(response => { if (window.assistantPollPositionTaskId !== taskId) { - reject(new Error('pollTaskPosition cancelled')) + safeReject(new TaskPollCancelledError('pollTaskPosition cancelled')) return } + const taskPosition = response.data?.ocs?.data if (obj) { callback(taskPosition, obj) } - }).catch(error => { if (window.assistantPollPositionTaskId === taskId) { - clearInterval(window.assistantPollPositionTimerId) - window.assistantPollPositionTimerId = null - window.assistantPollPositionTaskId = null + window.assistantPollPositionRetryTimerId = null + window.assistantPollPositionTimerId = setTimeout(pollPositionOnce, 5000) } + }).catch(error => { if (isCancel(error)) { console.debug('[assistant] pollPosition request cancelled', error) - reject(new Error('pollTaskPosition request cancelled')) + safeReject(new TaskPollCancelledError('pollTaskPosition request cancelled')) return } + + const status = error?.response?.status ?? error?.status console.debug('[assistant] pollPosition request failed', error) - if (error.status === 404) { - reject(new Error('task-not-found')) + + if (status === 404) { + safeReject(new Error('task-not-found')) return - } else if (error.status === 412) { - // the task is not scheduled anymore - resolve() + } + if (status === 412) { + safeResolve() + return + } + if (status >= 400 && status < 500) { + safeReject(new Error('pollTaskPosition non-retryable error: ' + status)) return } - reject(new Error('pollTaskPosition request failed')) + + console.warn('[assistant] pollPosition temporary failure, will retry in ' + retryDelay + 'ms', error) + if (window.assistantPollPositionTaskId === taskId) { + window.assistantPollPositionRetryTimerId = setTimeout(() => { + retryDelay = Math.min(retryDelay * 2, 60000) + pollPositionOnce() + }, retryDelay) + } else { + safeReject(new TaskPollCancelledError('pollTaskPosition cancelled during retry backoff')) + } }) } - cancelTaskPositionPolling() - window.assistantPollPositionTaskId = taskId - window.assistantPollPositionAbortController = new AbortController() - window.assistantPollPositionTimerId = setInterval(pollPositionOnce, 5000) - // start polling immediately + pollPositionOnce() }) } @@ -497,58 +594,124 @@ export async function pollTaskPosition(taskId, obj, callback = updateTaskPositio * @return {Promise} */ export async function pollTask(taskId, obj, updateOutput = true, callback = updateTask) { + const { isCancel } = await import('@nextcloud/axios') return new Promise((resolve, reject) => { + cancelTaskPolling() + window.assistantPollTaskId = taskId + const abortController = new AbortController() + window.assistantPollAbortController = abortController + let retryDelay = 5000 + let settled = false + + const cleanup = () => { + clearTimeout(window.assistantPollTimerId) + clearTimeout(window.assistantPollRetryTimerId) + window.assistantPollTimerId = null + window.assistantPollRetryTimerId = null + if (window.assistantPollTaskId === taskId) { + window.assistantPollTaskId = null + window.assistantPollAbortController = null + } + } + + const safeReject = (err) => { + if (!settled) { + settled = true + cleanup() + reject(err) + } + } + + const safeResolve = (val) => { + if (!settled) { + settled = true + cleanup() + resolve(val) + } + } + + abortController.signal.addEventListener('abort', () => { + safeReject(new TaskPollCancelledError('pollTask aborted')) + }) + const pollOnce = () => { - getTask(taskId, window.assistantPollAbortController.signal).then(response => { - const task = response.data?.ocs?.data?.task + if (window.assistantPollTaskId !== taskId) { + safeReject(new TaskPollCancelledError()) + return + } + + getTask(taskId, abortController.signal).then(response => { if (window.assistantPollTaskId !== taskId) { - reject(new Error('pollTask cancelled')) + safeReject(new TaskPollCancelledError()) return } + const task = response.data?.ocs?.data?.task if (obj) { callback(task, obj, updateOutput) } if (![TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(task?.status)) { - // stop polling - clearInterval(window.assistantPollTimerId) - window.assistantPollTimerId = null - resolve(task) + safeResolve(task) + } else if (window.assistantPollTaskId === taskId) { + window.assistantPollTimerId = setTimeout(pollOnce, 2000) } }).catch(error => { + if (isCancel(error)) { + console.debug('[assistant] poll request cancelled', error) + safeReject(new TaskPollCancelledError()) + return + } + + const status = error?.response?.status ?? error?.status console.debug('[assistant] poll request failed', error) - if (error.status === 404) { - if (window.assistantPollTaskId === taskId) { - clearInterval(window.assistantPollTimerId) - window.assistantPollTimerId = null - window.assistantPollTaskId = null - } - reject(new Error('task-not-found')) + + if (status === 404) { + safeReject(new Error('task-not-found')) return } - reject(new Error('pollTask request failed')) + if (status >= 400 && status < 500) { + safeReject(new Error('pollTask non-retryable error: ' + status)) + return + } + + console.warn('[assistant] poll temporary failure, will retry in ' + retryDelay + 'ms', error) + if (window.assistantPollTaskId === taskId) { + window.assistantPollRetryTimerId = setTimeout(() => { + retryDelay = Math.min(retryDelay * 2, 60000) + pollOnce() + }, retryDelay) + } else { + safeReject(new TaskPollCancelledError('pollTask cancelled during retry backoff')) + } }) } - cancelTaskPolling() - window.assistantPollTaskId = taskId - window.assistantPollAbortController = new AbortController() - // start polling immediately - // pollOnce() - window.assistantPollTimerId = setInterval(pollOnce, 2000) + + pollOnce() }) } export async function cancelTaskPolling() { window.assistantPollAbortController?.abort() - clearInterval(window.assistantPollTimerId) + clearTimeout(window.assistantPollTimerId) + clearTimeout(window.assistantPollRetryTimerId) window.assistantPollTimerId = null + window.assistantPollRetryTimerId = null window.assistantPollTaskId = null + window.assistantPollAbortController = null } export async function cancelTaskPositionPolling() { window.assistantPollPositionAbortController?.abort() - clearInterval(window.assistantPollPositionTimerId) + clearTimeout(window.assistantPollPositionTimerId) + clearTimeout(window.assistantPollPositionRetryTimerId) window.assistantPollPositionTimerId = null + window.assistantPollPositionRetryTimerId = null window.assistantPollPositionTaskId = null + window.assistantPollPositionAbortController = null +} + +export async function cancelScheduling() { + window.assistantSchedulingAbortController?.abort() + window.assistantSchedulingAbortController = null } export async function getTask(taskId, signal = null) { @@ -597,9 +760,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') { @@ -613,7 +777,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) { @@ -802,17 +967,30 @@ 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) + const controller = new AbortController() + window.assistantSchedulingAbortController = controller + scheduleTask(task.appId, task.identifier ?? '', data.detail.selectedTaskTypeId, data.detail.inputs, controller.signal) .then((response) => { + if (window.assistantSchedulingAbortController !== controller) { + return + } + cancelScheduling() console.debug('scheduled task', response.data?.ocs?.data?.task) }) .catch(error => { + if (controller.signal.aborted) { + return + } + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } app.unmount() OCA.Assistant.isAssistantDialogOpen = false console.error('Assistant scheduling error', error) @@ -823,6 +1001,7 @@ export async function openAssistantTask( }) }) const syncSubmit = (inputs, taskTypeId, newTaskCustomId = '') => { + cancelScheduling() view.loading = true view.showSyncTaskRunning = true view.taskPosition = null @@ -834,8 +1013,14 @@ export async function openAssistantTask( view.outputs = null view.selectedTaskTypeId = taskTypeId - scheduleTask('assistant', newTaskCustomId, taskTypeId, inputs) + const controller = new AbortController() + window.assistantSchedulingAbortController = controller + scheduleTask('assistant', newTaskCustomId, taskTypeId, inputs, controller.signal) .then((response) => { + if (window.assistantSchedulingAbortController !== controller) { + return + } + cancelScheduling() const task = response.data?.ocs?.data?.task lastTask = task view.selectedTaskId = lastTask?.id @@ -848,6 +1033,9 @@ export async function openAssistantTask( pollTaskPosition(task.id, view).then(() => { console.debug('[assistant] pollTaskPosition: the task is not scheduled anymore', task.id) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] pollPosition error', task.id, error.message) }) pollTask(task.id, view, !hasPush).then(finishedTask => { @@ -875,6 +1063,9 @@ export async function openAssistantTask( cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] poll error', error) view.outputs = null view.taskPosition = null @@ -890,6 +1081,12 @@ export async function openAssistantTask( }) }) .catch(error => { + if (controller.signal.aborted) { + return + } + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } view.loading = false view.showSyncTaskRunning = false view.taskPosition = null @@ -901,11 +1098,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 @@ -922,6 +1121,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 +1138,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) @@ -953,6 +1159,9 @@ export async function openAssistantTask( pollTaskPosition(updatedTask.id, view).then(() => { console.debug('[assistant] pollTaskPosition: the task is not scheduled anymore', updatedTask.id) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] pollPosition error', updatedTask.id, error.message) }) pollTask(updatedTask.id, view, !hasPush).then(finishedTask => { @@ -982,6 +1191,9 @@ export async function openAssistantTask( cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] poll error', error) view.taskPosition = null cancelTaskPositionPolling() @@ -1002,6 +1214,7 @@ export async function openAssistantTask( }) modalMountPoint.addEventListener('new-task', () => { console.debug('[assistant] new task') + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() view.loading = false @@ -1019,6 +1232,7 @@ export async function openAssistantTask( }) }) modalMountPoint.addEventListener('cancel-task', () => { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() setNotifyReady(lastTask.id, false) 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) diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index e3c3dca3..8cd9faab 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, @@ -53,6 +54,7 @@ import { pollTaskPosition, scheduleTask, setNotifyReady, + TaskPollCancelledError, } from '../assistant.js' import { TASK_STATUS_STRING } from '../constants.js' @@ -117,6 +119,7 @@ export default { } }, onCancel() { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() if (this.task?.id) { @@ -159,6 +162,7 @@ export default { return hasPush }, syncSubmit(inputs, taskTypeId, newTaskIdentifier = '') { + cancelScheduling() this.loading = true this.showSyncTaskRunning = true this.taskPosition = null @@ -170,8 +174,14 @@ export default { this.task.input = inputs this.task.output = null this.task.type = taskTypeId - scheduleTask('assistant', this.task.identifier, taskTypeId, inputs) + const controller = new AbortController() + window.assistantSchedulingAbortController = controller + scheduleTask('assistant', this.task.identifier, taskTypeId, inputs, controller.signal) .then((response) => { + if (window.assistantSchedulingAbortController !== controller) { + return + } + cancelScheduling() console.debug('Assistant SYNC result', response.data?.ocs?.data) const task = response.data?.ocs?.data?.task this.task.id = task.id @@ -185,6 +195,9 @@ export default { pollTaskPosition(task.id, this).then(() => { console.debug('[assistant] pollTaskPosition finished', task.id) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] pollPosition error', task.id, error.message) }) pollTask(task.id, this, !hasPush, this.updateTask).then(finishedTask => { @@ -202,6 +215,9 @@ export default { cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] poll error', error) this.taskPosition = null cancelTaskPositionPolling() @@ -217,6 +233,12 @@ export default { }) }) .catch(error => { + if (controller.signal.aborted) { + return + } + if (window.assistantSchedulingAbortController === controller) { + cancelScheduling() + } this.loading = false this.showSyncTaskRunning = false this.taskPosition = null @@ -241,9 +263,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 @@ -258,6 +282,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)) { @@ -281,6 +309,9 @@ export default { pollTaskPosition(updatedTask.id, this).then(() => { console.debug('[assistant] pollTaskPosition finished', updatedTask.id) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('[assistant] pollPosition error', updatedTask.id, error.message) }) pollTask(updatedTask.id, this, !hasPush, this.updateTask).then(finishedTask => { @@ -300,6 +331,9 @@ export default { cancelTaskPositionPolling() emit('assistant:task:updated', finishedTask) }).catch(error => { + if (error instanceof TaskPollCancelledError) { + return + } console.debug('Assistant poll error', error) this.taskPosition = null cancelTaskPositionPolling() @@ -319,6 +353,7 @@ export default { } }, onNewTask() { + cancelScheduling() cancelTaskPolling() cancelTaskPositionPolling() this.loading = false From e928653de7a05cd153ef67df9734c136eb51b88e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 31 Aug 2026 18:48:51 +0200 Subject: [PATCH 8/8] adjust the chat UI to use the same position polling logic as the generic form Signed-off-by: Julien Veyssier --- .../ChattyLLM/ChattyLLMInputForm.vue | 112 +++++++++++++++--- 1 file changed, 94 insertions(+), 18 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index dc238c9e..51da553d 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -259,7 +259,11 @@ 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 { + cancelTaskPositionPolling, + pollTaskPosition, + TaskPollCancelledError, +} from '../../assistant.js' import navAutoCollapse from '../../mixins/navAutoCollapse.js' @@ -325,6 +329,8 @@ export default { sessions: null, assignmentDetails: null, pollCheckSessionTimeout: null, + pollPositionTaskId: null, + pollPositionSessionId: null, // [{ id: number, session_id: number, role: string, content: string, timestamp: number, sources:string }] messages: [], // null when failed to fetch streamingMessage: null, @@ -351,6 +357,7 @@ export default { titleActionsOpen: false, editingTitle: false, pollMessageGenerationTimerId: null, + pollMessageGenerationCancel: null, pollTitleGenerationTimerId: null, autoplayAudioChat: loadState('assistant', 'autoplay_audio_chat', true), slowPickup: false, @@ -442,6 +449,10 @@ export default { watch: { async active() { + this.pollMessageGenerationCancel?.() + cancelTaskPositionPolling() + this.pollPositionTaskId = null + this.pollPositionSessionId = null this.allMessagesLoaded = false this.loading.llmGeneration = false this.loading.llmRunning = false @@ -476,6 +487,8 @@ export default { }, beforeUnmount() { + this.pollMessageGenerationCancel?.() + cancelTaskPositionPolling() if (this.pollMessageGenerationTimerId) { clearInterval(this.pollMessageGenerationTimerId) } @@ -524,6 +537,8 @@ export default { if (checkSessionResponseData.messageTaskId !== null) { try { this.loading.llmGeneration = true + this.loading.llmRunning = false + this.startTaskPositionPolling(checkSessionResponseData.messageTaskId, sessionId) this.userScrolled = false const message = await this.pollGenerationTask(checkSessionResponseData.messageTaskId, sessionId) console.debug('checkTaskPolling result:', message) @@ -534,6 +549,9 @@ export default { this.focusOnInputField() } } catch (error) { + if (error instanceof TaskPollCancelledError) { + return + } console.error('checkGenerationTask error:', error) showError(error?.response?.data?.userFacingErrorMessage ?? t('assistant', 'Error generating a response')) } @@ -563,6 +581,11 @@ export default { console.error('check session error:', error) showError(t('assistant', 'Error checking if the session is thinking')) } finally { + if (this.pollPositionSessionId === sessionId) { + cancelTaskPositionPolling() + this.pollPositionTaskId = null + this.pollPositionSessionId = null + } this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -947,6 +970,7 @@ export default { const generationResponse = await axios.get(getChatURL('/generate'), { params }) const generationResponseData = generationResponse.data console.debug('scheduleGenerationTask response:', generationResponseData) + this.startTaskPositionPolling(generationResponseData.taskId, sessionId) const message = await this.pollGenerationTask(generationResponseData.taskId, sessionId) console.debug('checkTaskPolling result:', message) this.messages.push(message) @@ -956,9 +980,17 @@ export default { this.focusOnInputField() } } catch (error) { + if (error instanceof TaskPollCancelledError) { + return + } console.error('scheduleGenerationTask error:', error) showError(error?.response?.data?.userFacingErrorMessage ?? t('assistant', 'Error generating a response')) } finally { + if (this.pollPositionSessionId === sessionId) { + cancelTaskPositionPolling() + this.pollPositionTaskId = null + this.pollPositionSessionId = null + } this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -968,8 +1000,8 @@ export default { }, async runRegenerationTask(messageId) { + const sessionId = this.active.id try { - const sessionId = this.active.id this.loading.llmGeneration = true this.loading.llmRunning = false this.loading.taskPosition = null @@ -977,6 +1009,7 @@ export default { const regenerationResponse = await axios.get(getChatURL('/regenerate'), { params: { messageId, sessionId } }) const regenerationResponseData = regenerationResponse.data console.debug('scheduleRegenerationTask response:', regenerationResponse) + this.startTaskPositionPolling(regenerationResponseData.taskId, sessionId) const message = await this.pollGenerationTask(regenerationResponseData.taskId, sessionId) console.debug('checkTaskPolling result:', message) this.messages[this.messages.length - 1] = message @@ -986,9 +1019,17 @@ export default { this.focusOnInputField() } } catch (error) { + if (error instanceof TaskPollCancelledError) { + return + } console.error('scheduleRegenerationTask error:', error) showError(error?.response?.data?.userFacingErrorMessage ?? t('assistant', 'Error regenerating a response')) } finally { + if (this.pollPositionSessionId === sessionId) { + cancelTaskPositionPolling() + this.pollPositionTaskId = null + this.pollPositionSessionId = null + } this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -1025,23 +1066,55 @@ export default { return hasPush }, + startTaskPositionPolling(taskId, sessionId) { + if (this.pollPositionTaskId === taskId && this.pollPositionSessionId === sessionId) { + return + } + + cancelTaskPositionPolling() + this.pollPositionTaskId = taskId + this.pollPositionSessionId = sessionId + pollTaskPosition(taskId, this, (position) => { + if (this.pollPositionTaskId !== taskId + || this.pollPositionSessionId !== sessionId + || this.active?.id !== sessionId) { + return + } + this.loading.taskPosition = Number.isFinite(Number(position)) ? Number(position) : null + }).catch(error => { + if (!(error instanceof TaskPollCancelledError)) { + console.error('Failed to poll task position', error) + } + }) + }, + async pollGenerationTask(taskId, sessionId) { const hasPush = this.listenToTaskNotifications(taskId, sessionId) console.debug('[assistant] HAS PUSH', hasPush) return new Promise((resolve, reject) => { - this.pollMessageGenerationTimerId = setInterval(() => { + const timerId = setInterval(() => { if (this.active === null || sessionId !== this.active.id) { console.debug('Stop polling messages for session ' + sessionId + ' because it is not selected anymore') - clearInterval(this.pollMessageGenerationTimerId) + clearInterval(timerId) + if (this.pollMessageGenerationTimerId === timerId) { + this.pollMessageGenerationTimerId = null + this.pollMessageGenerationCancel = null + } + reject(new TaskPollCancelledError('generation polling cancelled')) return } axios.get( getChatURL('/check_generation'), { params: { taskId, sessionId } }, ).then(response => { + if (this.pollMessageGenerationTimerId !== timerId) { + return + } const responseData = response.data - clearInterval(this.pollMessageGenerationTimerId) + clearInterval(timerId) + this.pollMessageGenerationTimerId = null + this.pollMessageGenerationCancel = null if (sessionId === this.active.id) { this.active.sessionAgencyPendingActions = responseData.sessionAgencyPendingActions this.active.agencyAnswered = false @@ -1062,10 +1135,15 @@ export default { // should we reject here? } }).catch(error => { + if (this.pollMessageGenerationTimerId !== timerId) { + return + } // do not reject if response code is Http::STATUS_EXPECTATION_FAILED (417) if (error.response?.status !== 417) { console.error('checkTaskPolling error', error) - clearInterval(this.pollMessageGenerationTimerId) + clearInterval(timerId) + this.pollMessageGenerationTimerId = null + this.pollMessageGenerationCancel = null reject(error) } else { console.debug('checkTaskPolling, task is still scheduled or running') @@ -1073,18 +1151,7 @@ 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 => { - if (sessionId !== this.active?.id) { - return - } - 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) - }) + this.startTaskPositionPolling(taskId, sessionId) } if (!hasPush && typeof error.response.data.task_output !== 'undefined' && error.response.data.task_output !== null) { this.updateStreamingMessage(error.response.data.task_output || {}, sessionId) @@ -1092,6 +1159,15 @@ export default { } }) }, 2000) + this.pollMessageGenerationTimerId = timerId + this.pollMessageGenerationCancel = () => { + clearInterval(timerId) + if (this.pollMessageGenerationTimerId === timerId) { + this.pollMessageGenerationTimerId = null + } + reject(new TaskPollCancelledError('generation polling cancelled')) + this.pollMessageGenerationCancel = null + } }) },