From 341d30bed887afaa59d024ec17869cf78cfd212b Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 13 May 2026 11:43:37 +0200 Subject: [PATCH 01/34] feat(streaming): update task output while polling, display form if the task is running and has outputs Signed-off-by: Julien Veyssier --- src/assistant.js | 10 ++++++++-- src/components/AssistantTextProcessingForm.vue | 10 ++++++++-- src/views/AssistantPage.vue | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index d5b101806..6b24af7fb 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -136,6 +136,7 @@ export async function openAssistantForm({ view.progress = null view.expectedRuntime = null view.inputs = inputs + view.outputs = null view.selectedTaskTypeId = taskTypeId scheduleTask(appId, newTaskCustomId, taskTypeId, inputs) @@ -323,11 +324,12 @@ function updateTask(task, object) { } object.taskStatus = task?.status object.scheduledAt = task?.scheduledAt + object.outputs = task?.output } export async function pollTask(taskId, obj, callback = updateTask) { return new Promise((resolve, reject) => { - window.assistantPollTimerId = setInterval(() => { + const pollOnce = () => { getTask(taskId).then(response => { const task = response.data?.ocs?.data?.task if (window.assistantPollTimerId === null) { @@ -353,7 +355,10 @@ export async function pollTask(taskId, obj, callback = updateTask) { } reject(new Error('pollTask request failed')) }) - }, 2000) + } + // start polling immediately + // pollOnce() + window.assistantPollTimerId = setInterval(pollOnce, 2000) }) } @@ -606,6 +611,7 @@ export async function openAssistantTask( view.isNotifyEnabled = false view.expectedRuntime = null view.inputs = inputs + view.outputs = null view.selectedTaskTypeId = taskTypeId scheduleTask('assistant', newTaskCustomId, taskTypeId, inputs) diff --git a/src/components/AssistantTextProcessingForm.vue b/src/components/AssistantTextProcessingForm.vue index 48e0e42e8..9feb997cc 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -42,7 +42,7 @@ { From 931d1379821e38256a218c1e6ecb714aa93db81c Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 13 May 2026 12:25:58 +0200 Subject: [PATCH 02/34] feat(streaming): show notify and cancel buttons in the task header when displaying intermediate results (running + has output) Signed-off-by: Julien Veyssier --- src/assistant.js | 4 ++ .../AssistantTextProcessingForm.vue | 41 ++++++++++++++++++- src/views/AssistantPage.vue | 2 + 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index 6b24af7fb..2c365d5e7 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -304,6 +304,8 @@ export async function openAssistantForm({ view.loading = false view.showSyncTaskRunning = false view.selectedTaskId = null + view.outputs = null + view.taskStatus = null lastTask = null }) }) @@ -769,6 +771,8 @@ export async function openAssistantTask( view.loading = false view.showSyncTaskRunning = false view.selectedTaskId = null + view.outputs = null + view.taskStatus = null lastTask = null }) }) diff --git a/src/components/AssistantTextProcessingForm.vue b/src/components/AssistantTextProcessingForm.vue index 9feb997cc..39ddcbd94 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -74,6 +74,25 @@ +
+ {{ t('assistant', 'Getting results…') }} + + + {{ t('assistant', 'Get notified when the task finishes') }} + + + + {{ t('assistant', 'Cancel task') }} + +
@@ -151,6 +170,9 @@ import PlusIcon from 'vue-material-design-icons/Plus.vue' import UnfoldLessHorizontalIcon from 'vue-material-design-icons/UnfoldLessHorizontal.vue' import UnfoldMoreHorizontalIcon from 'vue-material-design-icons/UnfoldMoreHorizontal.vue' import InformationBoxIcon from 'vue-material-design-icons/InformationBox.vue' +import BellOutlineIcon from 'vue-material-design-icons/BellOutline.vue' +import BellRingOutlineIcon from 'vue-material-design-icons/BellRingOutline.vue' +import CloseIcon from 'vue-material-design-icons/Close.vue' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActions from '@nextcloud/vue/components/NcActions' @@ -210,6 +232,9 @@ export default { UnfoldLessHorizontalIcon, UnfoldMoreHorizontalIcon, InformationBoxIcon, + BellOutlineIcon, + BellRingOutlineIcon, + CloseIcon, AssistantFormInputs, AssistantFormOutputs, ChattyLLMInputForm, @@ -415,6 +440,9 @@ export default { showRunningEmptyContent() { return this.showSyncTaskRunning && this.myOutputs === null }, + showSubtitle() { + return this.showSyncTaskRunning && this.myOutputs !== null + }, }, watch: { outputs(newVal) { @@ -835,12 +863,12 @@ export default { &__top-bar { display: flex; + flex-direction: column; justify-content: space-between; align-items: center; - gap: 4px; position: sticky; top: 0; - height: calc(var(--default-clickable-area) + var(--default-grid-baseline) * 2); + // height: calc(var(--default-clickable-area) + var(--default-grid-baseline) * 2); box-sizing: border-box; border-bottom: 1px solid var(--color-border); padding-left: 52px; @@ -858,6 +886,15 @@ export default { white-space: nowrap; } + &__subtitle { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + width: 100%; + padding: 4px 0 4px 10px; + } + &__provider { font-weight: normal; font-size: 0.9em; diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index bc4ac2e10..2c4be9da3 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -107,6 +107,8 @@ export default { this.loading = false this.showSyncTaskRunning = false this.task.id = null + this.task.output = null + this.task.status = null }) }, syncSubmit(inputs, taskTypeId, newTaskIdentifier = '') { From 4754395838dbf42bc44e6e48597c6a3b3c5c4e26 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 13 May 2026 15:34:00 +0200 Subject: [PATCH 03/34] feat(streaming): adjust chat UI to display intermediate/streaming message Signed-off-by: Julien Veyssier --- lib/Controller/ChattyLLMController.php | 9 ++++++- .../ChattyLLM/ChattyLLMInputForm.vue | 25 +++++++++++++++++++ src/components/ChattyLLM/ConversationBox.vue | 12 ++++++++- src/components/ChattyLLM/Message.vue | 9 +++++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 2f3a7e245..bc24ce7a3 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -601,7 +601,14 @@ public function checkMessageGenerationTask(int $taskId, int $sessionId): JSONRes } elseif ($task->getstatus() === Task::STATUS_RUNNING || $task->getstatus() === Task::STATUS_SCHEDULED) { $startTime = $task->getStartedAt() ?? time(); $slowPickup = ($task->getScheduledAt() + (60 * 5)) < $startTime; - return new JSONResponse(['task_status' => $task->getstatus(), 'slow_pickup' => $slowPickup], Http::STATUS_EXPECTATION_FAILED); + $responsePayload = [ + 'task_status' => $task->getstatus(), + 'slow_pickup' => $slowPickup, + ]; + if ($task->getstatus() === Task::STATUS_RUNNING) { + $responsePayload['task_output'] = $task->getOutput(); + } + return new JSONResponse($responsePayload, Http::STATUS_EXPECTATION_FAILED); } elseif ($task->getstatus() === Task::STATUS_FAILED || $task->getstatus() === Task::STATUS_CANCELLED) { return new JSONResponse(['error' => 'task_failed_or_canceled', 'task_status' => $task->getstatus()], Http::STATUS_BAD_REQUEST); } diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 13833ca63..14bdbb321 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -149,6 +149,7 @@
{ const lastIdx = this.messages.length - 1 document.querySelector('#message' + lastIdx)?.scrollIntoView() + document.querySelector('#message-streaming')?.scrollIntoView() + document.querySelector('#message-placeholder')?.scrollIntoView() + this.$refs.inputComponent.focus() if (!this.isAssignment) { this.$refs.inputComponent.focus() } @@ -864,6 +871,7 @@ export default { async runGenerationTask(sessionId, agencyConfirm = null) { try { + this.scrollToBottom() this.slowPickup = false this.loading.llmGeneration = true this.loading.llmRunning = false @@ -887,6 +895,7 @@ export default { } finally { this.loading.llmGeneration = false this.loading.llmRunning = false + this.streamingMessage = null } }, @@ -908,6 +917,7 @@ export default { } finally { this.loading.llmGeneration = false this.loading.llmRunning = false + this.streamingMessage = null } }, @@ -956,6 +966,21 @@ export default { if (error.response.data.task_status === TASK_STATUS_INT.running) { this.loading.llmRunning = true } + if (error.response.data.task_output?.output) { + if (this.streamingMessage) { + this.streamingMessage.content = error.response.data.task_output.output + } else { + this.streamingMessage = { + role: Roles.ASSISTANT, + content: error.response.data.task_output.output, + attachments: [], + sources: '', + session_id: sessionId, + id: 0, + timestamp: moment().unix(), + } + } + } } }) }, 2000) diff --git a/src/components/ChattyLLM/ConversationBox.vue b/src/components/ChattyLLM/ConversationBox.vue index c73e03089..20e8f7fc5 100644 --- a/src/components/ChattyLLM/ConversationBox.vue +++ b/src/components/ChattyLLM/ConversationBox.vue @@ -31,7 +31,13 @@ :information-source-names="informationSourceNames" @regenerate="regenerate(message.id)" @delete="deleteMessage(message.id)" /> - + + @@ -83,6 +89,10 @@ export default { type: Boolean, default: false, }, + streamingMessage: { + type: Object, + default: null, + }, }, emits: ['delete', 'regenerate'], diff --git a/src/components/ChattyLLM/Message.vue b/src/components/ChattyLLM/Message.vue index 236164954..b9f097632 100644 --- a/src/components/ChattyLLM/Message.vue +++ b/src/components/ChattyLLM/Message.vue @@ -17,13 +17,14 @@ @delete="$emit('delete')" />
+ @@ -135,6 +136,10 @@ export default { type: Boolean, default: false, }, + streaming: { + type: Boolean, + default: false, + }, informationSourceNames: { type: Object, default: null, From b87b94efbbdfa62922faa67368de206cace6d79b Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 13 May 2026 15:48:36 +0200 Subject: [PATCH 04/34] make the dialog initial width 70% Signed-off-by: Julien Veyssier --- src/components/AssistantTextProcessingModal.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AssistantTextProcessingModal.vue b/src/components/AssistantTextProcessingModal.vue index 58a370e3a..3edd6b208 100644 --- a/src/components/AssistantTextProcessingModal.vue +++ b/src/components/AssistantTextProcessingModal.vue @@ -217,7 +217,7 @@ export default { height: calc(100vh - 32px); max-height: calc(100vh - 32px); height: 80%; - width: 50%; + width: 70%; resize: both; overflow: hidden; filter: drop-shadow(0 0 15px rgba(77, 77, 77, 0.5)); From e67553485e4d2691cafc15361a40a82d73b07d5f Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 13 May 2026 15:56:57 +0200 Subject: [PATCH 05/34] add a loading icon in the output form while streaming Signed-off-by: Julien Veyssier --- src/components/AssistantTextProcessingForm.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/AssistantTextProcessingForm.vue b/src/components/AssistantTextProcessingForm.vue index 39ddcbd94..2f471dfd2 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -61,7 +61,8 @@ @@ -74,7 +75,7 @@
-
{{ t('assistant', 'Getting results…') }} Date: Wed, 13 May 2026 16:53:02 +0200 Subject: [PATCH 06/34] add @nextcloud/notify_push Signed-off-by: Julien Veyssier --- package-lock.json | 59 +++++++++++++++++++++++++++++------------------ package.json | 1 + 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7e04b2285..dec89d06a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.0", "@nextcloud/moment": "^1.3.1", + "@nextcloud/notify_push": "^1.4.0", "@nextcloud/router": "^3.0.0", "@nextcloud/vue": "^9.0.0-rc.5", "@primeuix/themes": "^2.0.3", @@ -2871,27 +2872,27 @@ } }, "node_modules/@nextcloud/auth": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.5.3.tgz", - "integrity": "sha512-KIhWLk0BKcP4hvypE4o11YqKOPeFMfEFjRrhUUF+h7Fry+dhTBIEIxuQPVCKXMIpjTDd8791y8V6UdRZ2feKAQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.6.0.tgz", + "integrity": "sha512-VkT87+9UqpPi7O36bVEE4/MxWF8d90VQcuMlvKltsZyLSLkEGrPXgowtD75Y54k60/8SR6mXbeqBwapi8dDUbA==", "license": "GPL-3.0-or-later", "dependencies": { "@nextcloud/browser-storage": "^0.5.0", - "@nextcloud/event-bus": "^3.3.2" + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/router": "^3.1.0" }, "engines": { "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, "node_modules/@nextcloud/axios": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@nextcloud/axios/-/axios-2.5.2.tgz", - "integrity": "sha512-8frJb77jNMbz00TjsSqs1PymY0nIEbNM4mVmwen2tXY7wNgRai6uXilIlXKOYB9jR/F/HKRj6B4vUwVwZbhdbw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@nextcloud/axios/-/axios-2.6.0.tgz", + "integrity": "sha512-ehcIgyora8DAJ+STG6iFI4e+ufPVFrIA6o0FgMKeKdfyaxRJ9UM7L+n7V+rc/qv8sDiWC/hWIKwFtLw2W5yE4Q==", "license": "GPL-3.0-or-later", "dependencies": { - "@nextcloud/auth": "^2.5.1", - "@nextcloud/router": "^3.0.1", - "axios": "^1.12.2" + "@nextcloud/auth": "^2.6.0", + "axios": "^1.15.0" }, "engines": { "node": "^20.0.0 || ^22.0.0 || ^24.0.0" @@ -3173,6 +3174,17 @@ "npm": "^10.0.0" } }, + "node_modules/@nextcloud/notify_push": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@nextcloud/notify_push/-/notify_push-1.4.0.tgz", + "integrity": "sha512-07UDgz1xLG9XABP8+mwQ2CsNWZu6lKzz0ErUA2HfE1ZfxXKiwVpo60t30y34UExGB9+Ok1nFaYU8fyJHncz9aQ==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/axios": "^2.6.0", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/event-bus": "^3.3.3" + } + }, "node_modules/@nextcloud/paths": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz", @@ -5859,14 +5871,14 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-polyfill-corejs2": { @@ -8667,9 +8679,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -12303,10 +12315,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/public-encrypt": { "version": "4.0.3", diff --git a/package.json b/package.json index f32e691f4..453f0d804 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.0", "@nextcloud/moment": "^1.3.1", + "@nextcloud/notify_push": "^1.4.0", "@nextcloud/router": "^3.0.0", "@nextcloud/vue": "^9.0.0-rc.5", "@primeuix/themes": "^2.0.3", From ff7ed6f66f8b6bb141508e02172eda00bbdbcf4d Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 13 May 2026 16:53:26 +0200 Subject: [PATCH 07/34] feat(streaming): use notify_push to get the polled task's output Signed-off-by: Julien Veyssier --- src/assistant.js | 53 ++++++++++++++++++++++++++++++++----- src/views/AssistantPage.vue | 19 +++++++++++-- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index 2c365d5e7..9b92cfa8b 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -8,6 +8,7 @@ import { showError } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' import PrimeVue from 'primevue/config' import Aura from '@primeuix/themes/aura' +import { listen } from '@nextcloud/notify_push' window.assistantPollTimerId = null @@ -146,7 +147,21 @@ export async function openAssistantForm({ view.selectedTaskId = lastTask?.id view.expectedRuntime = (lastTask?.completionExpectedAt - lastTask?.scheduledAt) || null - pollTask(task.id, view).then(finishedTask => { + // attempt to listen to push notifications to get the intermediate output + const pushTaskId = task.id + const pushChannel = 'task_' + pushTaskId + const hasPush = listen(pushChannel, (type, body) => { + console.debug('[assistant] received push notification', type, body) + if (pushTaskId === view.selectedTaskId) { + view.outputs = body + } else { + console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', view.selectedTaskId) + } + }) + console.debug('[assistant] HAS PUSH', hasPush) + + // 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) if (finishedTask.status === TASK_STATUS_STRING.successful) { if (closeOnResult) { @@ -320,16 +335,28 @@ export async function openAssistantForm({ }) } -function updateTask(task, object) { +function updateTask(task, object, updateOutput = true) { if (task?.status === TASK_STATUS_STRING.running) { object.progress = task?.progress * 100 } object.taskStatus = task?.status object.scheduledAt = task?.scheduledAt - object.outputs = task?.output + if (updateOutput) { + console.debug('[assistant] polling update output') + object.outputs = task?.output + } } -export async function pollTask(taskId, obj, callback = updateTask) { +/** + * Poll the task to update its status + * + * @param {number} taskId the task ID + * @param {object} obj the object to update + * @param {boolean} updateOutput whether to update the task output from the polling data or not + * @param {Function} callback the function to call to update the object + * @return {Promise<*>} + */ +export async function pollTask(taskId, obj, updateOutput = true, callback = updateTask) { return new Promise((resolve, reject) => { const pollOnce = () => { getTask(taskId).then(response => { @@ -339,7 +366,7 @@ export async function pollTask(taskId, obj, callback = updateTask) { return } if (obj) { - callback(task, obj) + callback(task, obj, updateOutput) } if (![TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(task?.status)) { // stop polling @@ -622,7 +649,21 @@ export async function openAssistantTask( lastTask = task view.selectedTaskId = lastTask?.id view.expectedRuntime = (lastTask?.completionExpectedAt - lastTask?.scheduledAt) || null - pollTask(task.id, view).then(finishedTask => { + + // attempt to listen to push notifications to get the intermediate output + const pushTaskId = task.id + const pushChannel = 'task_' + pushTaskId + const hasPush = listen(pushChannel, (type, body) => { + console.debug('[assistant] received push notification', type, body) + if (pushTaskId === view.selectedTaskId) { + view.outputs = body + } else { + console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', view.selectedTaskId) + } + }) + console.debug('[assistant] HAS PUSH', hasPush) + + pollTask(task.id, view, !hasPush).then(finishedTask => { if (finishedTask.status === TASK_STATUS_STRING.successful) { view.outputs = finishedTask?.output } else if (finishedTask.status === TASK_STATUS_STRING.failed) { diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index 2c4be9da3..0798d5822 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -40,6 +40,7 @@ import AssistantTextProcessingForm from '../components/AssistantTextProcessingFo import { showError } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' +import { listen } from '@nextcloud/notify_push' import { cancelTask, cancelTaskPolling, @@ -128,7 +129,21 @@ export default { this.task.id = task.id this.task.completionExpectedAt = task.completionExpectedAt this.task.scheduledAt = task.scheduledAt - pollTask(task.id, this, this.updateTask).then(finishedTask => { + + // attempt to listen to push notifications to get the intermediate output + const pushTaskId = task.id + const pushChannel = 'task_' + pushTaskId + const hasPush = listen(pushChannel, (type, body) => { + console.debug('[assistant] received push notification', type, body) + if (pushTaskId === this.task.id) { + this.task.output = body + } else { + console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', this.task.id) + } + }) + console.debug('[assistant] HAS PUSH', hasPush) + + pollTask(task.id, this, !hasPush, this.updateTask).then(finishedTask => { if (finishedTask.status === TASK_STATUS_STRING.successful) { this.task.output = finishedTask?.output } else if (finishedTask.status === TASK_STATUS_STRING.failed) { @@ -202,7 +217,7 @@ export default { this.task.completionExpectedAt = updatedTask.completionExpectedAt this.task.scheduledAt = updatedTask.scheduledAt - pollTask(updatedTask.id, this, this.updateTask).then(finishedTask => { + pollTask(updatedTask.id, this, true, this.updateTask).then(finishedTask => { console.debug('pollTask.then', finishedTask) if (finishedTask.status === TASK_STATUS_STRING.successful) { this.task.output = finishedTask?.output From c13c7ce9ab6443e069d1f73661d8ef4a8f771f07 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 13 May 2026 17:16:34 +0200 Subject: [PATCH 08/34] feat(streaming): use notify_push to get the polled chat message generation task's output Signed-off-by: Julien Veyssier --- .../ChattyLLM/ChattyLLMInputForm.vue | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 14bdbb321..625e9cfb5 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -249,6 +249,7 @@ import axios, { isCancel } from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' import { generateUrl, generateOcsUrl } from '@nextcloud/router' import { loadState } from '@nextcloud/initial-state' +import { listen } from '@nextcloud/notify_push' import moment from 'moment' import { SHAPE_TYPE_NAMES, TASK_STATUS_INT } from '../../constants.js' import ICAL from 'ical.js' @@ -922,6 +923,27 @@ export default { }, async pollGenerationTask(taskId, sessionId) { + // attempt to listen to push notifications to get the intermediate output + const pushTaskId = taskId + const pushChannel = 'task_' + pushTaskId + const pushSessionId = this.active.id + const hasPush = listen(pushChannel, (type, body) => { + console.debug('[assistant] received push notification', type, body) + if (pushSessionId === this.active.id) { + this.updateStreamingMessage(body.output, sessionId) + } else { + console.debug( + '[assistant] ignoring push notification for task', + pushTaskId, + 'in session', + pushSessionId, + 'the selected session is', + this.active.id, + ) + } + }) + console.debug('[assistant] HAS PUSH', hasPush) + return new Promise((resolve, reject) => { this.pollMessageGenerationTimerId = setInterval(() => { if (this.active === null || sessionId !== this.active.id) { @@ -967,19 +989,7 @@ export default { this.loading.llmRunning = true } if (error.response.data.task_output?.output) { - if (this.streamingMessage) { - this.streamingMessage.content = error.response.data.task_output.output - } else { - this.streamingMessage = { - role: Roles.ASSISTANT, - content: error.response.data.task_output.output, - attachments: [], - sources: '', - session_id: sessionId, - id: 0, - timestamp: moment().unix(), - } - } + this.updateStreamingMessage(error.response.data.task_output.output, sessionId) } } }) @@ -987,6 +997,22 @@ export default { }) }, + updateStreamingMessage(content, sessionId) { + if (this.streamingMessage) { + this.streamingMessage.content = content + } else { + this.streamingMessage = { + role: Roles.ASSISTANT, + content, + attachments: [], + sources: '', + session_id: sessionId, + id: 0, + timestamp: moment().unix(), + } + } + }, + getLastHumanMessage() { return this.messages .filter(m => m.role === Roles.HUMAN) From 22e64e7fad7f3376c04e59038c85625364be3f6f Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 18 May 2026 15:17:28 +0200 Subject: [PATCH 09/34] start listening to notify_push messages when loading a task in the generic form Signed-off-by: Julien Veyssier --- src/assistant.js | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index 9b92cfa8b..aa0be253c 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -124,6 +124,31 @@ export async function openAssistantForm({ const view = app.mount(modalMountPoint) let lastTask = null + // notify push stuff + const isListeningTo = {} + // listen only if needed + // return true if notify_push is available + // we can't cleanup isListeningTo because there is no way to remove a handler with @nextcloud/notify_push + const listenToTaskNotifications = (pushTaskId) => { + if (isListeningTo[pushTaskId]) { + return true + } + // attempt to listen to push notifications to get the intermediate output + const pushChannel = 'task_' + pushTaskId + const hasPush = listen(pushChannel, (type, body) => { + console.debug('[assistant] received push notification', type, body) + if (pushTaskId === view.selectedTaskId) { + view.outputs = body + } else { + console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', view.selectedTaskId) + } + }) + if (hasPush) { + isListeningTo[pushTaskId] = true + } + return hasPush + } + modalMountPoint.addEventListener('cancel', () => { cancelTaskPolling() app.unmount() @@ -147,17 +172,7 @@ export async function openAssistantForm({ view.selectedTaskId = lastTask?.id view.expectedRuntime = (lastTask?.completionExpectedAt - lastTask?.scheduledAt) || null - // attempt to listen to push notifications to get the intermediate output - const pushTaskId = task.id - const pushChannel = 'task_' + pushTaskId - const hasPush = listen(pushChannel, (type, body) => { - console.debug('[assistant] received push notification', type, body) - if (pushTaskId === view.selectedTaskId) { - view.outputs = body - } else { - console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', view.selectedTaskId) - } - }) + const hasPush = listenToTaskNotifications(task.id) console.debug('[assistant] HAS PUSH', hasPush) // no need to update the task output with polling if we have push notifications @@ -256,7 +271,10 @@ export async function openAssistantForm({ view.progress = null view.expectedRuntime = (updatedTask?.completionExpectedAt - updatedTask?.scheduledAt) || null - pollTask(updatedTask.id, view).then(finishedTask => { + const hasPush = listenToTaskNotifications(task.id) + console.debug('[assistant] HAS PUSH', hasPush) + + pollTask(updatedTask.id, view, !hasPush).then(finishedTask => { console.debug('pollTask.then', finishedTask) if (finishedTask.status === TASK_STATUS_STRING.successful) { view.outputs = finishedTask?.output From 8cc369a8543ff51a0264ef4121d92118272424a3 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 18 May 2026 15:32:52 +0200 Subject: [PATCH 10/34] prevent listening notify push msgs twice for the same task after switching chat sessions Signed-off-by: Julien Veyssier --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 625e9cfb5..5a9f57107 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -319,6 +319,7 @@ export default { // [{ id: number, session_id: number, role: string, content: string, timestamp: number, sources:string }] messages: [], // null when failed to fetch streamingMessage: null, + isListeningTo: {}, messagesAxiosController: null, // for request cancellation allMessagesLoaded: false, loading: { @@ -922,15 +923,16 @@ export default { } }, - async pollGenerationTask(taskId, sessionId) { + listenToTaskNotifications(pushTaskId, pushSessionId) { // attempt to listen to push notifications to get the intermediate output - const pushTaskId = taskId + if (this.isListeningTo[pushTaskId]) { + return true + } const pushChannel = 'task_' + pushTaskId - const pushSessionId = this.active.id const hasPush = listen(pushChannel, (type, body) => { console.debug('[assistant] received push notification', type, body) if (pushSessionId === this.active.id) { - this.updateStreamingMessage(body.output, sessionId) + this.updateStreamingMessage(body.output, pushSessionId) } else { console.debug( '[assistant] ignoring push notification for task', @@ -942,6 +944,12 @@ export default { ) } }) + this.isListeningTo[pushTaskId] = true + return hasPush + }, + + async pollGenerationTask(taskId, sessionId) { + const hasPush = this.listenToTaskNotifications(taskId, this.active.id) console.debug('[assistant] HAS PUSH', hasPush) return new Promise((resolve, reject) => { From 9b36ff69ec00a81887a2b64e22b1bd8192071205 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 18 May 2026 15:41:53 +0200 Subject: [PATCH 11/34] regenerate openapi specs, fix psalm issue Signed-off-by: Julien Veyssier --- lib/Controller/ChattyLLMController.php | 2 +- openapi.json | 27 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index bc24ce7a3..3d4c337a9 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -550,7 +550,7 @@ public function regenerateForSession(int $sessionId, int $messageId): JSONRespon * * @param int $taskId The message generation task ID * @param int $sessionId The chat session ID - * @return JSONResponse|JSONResponse|JSONResponse + * @return JSONResponse|JSONResponse|numeric|string>|null}, array{}>|JSONResponse * @throws MultipleObjectsReturnedException * @throws \OCP\DB\Exception * diff --git a/openapi.json b/openapi.json index 81508c8c8..8793d83b0 100644 --- a/openapi.json +++ b/openapi.json @@ -4874,6 +4874,33 @@ }, "slow_pickup": { "type": "boolean" + }, + "task_output": { + "type": "object", + "nullable": true, + "additionalProperties": { + "oneOf": [ + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } } } } From ad1e11006265dcc7456720fc045cbf577e5d03e3 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 18 May 2026 18:11:06 +0200 Subject: [PATCH 12/34] add simple pulse animation to output fields when streaming Signed-off-by: Julien Veyssier --- .../AssistantTextProcessingForm.vue | 1 + src/components/fields/TextInput.vue | 23 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/AssistantTextProcessingForm.vue b/src/components/AssistantTextProcessingForm.vue index 2f471dfd2..4315a27af 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -244,6 +244,7 @@ export default { provide() { return { providedCurrentTaskId: () => this.selectedTaskId, + streaming: () => this.streaming, } }, props: { diff --git a/src/components/fields/TextInput.vue b/src/components/fields/TextInput.vue index c88045eee..a86cf4765 100644 --- a/src/components/fields/TextInput.vue +++ b/src/components/fields/TextInput.vue @@ -17,7 +17,7 @@ :multiline="isMobile" :maxlength="maxLength" class="editable-input" - :class="{ shadowed: isOutput }" + :class="{ shadowed: isOutput, streaming: isOutput && streaming() }" :placeholder="placeholder" :title="title" @submit="hasValue && $emit('submit', $event)" @@ -87,6 +87,10 @@ export default { isMobile, ], + inject: [ + 'streaming', + ], + props: { id: { type: String, @@ -232,8 +236,23 @@ body[dir="rtl"] .choose-file-button { padding-bottom: 4px !important; } .shadowed .rich-contenteditable__input { - border: 2px solid var(--color-primary-element) !important; + border: 2px solid var(--color-primary-element); padding-bottom: 38px !important; } + .shadowed.streaming .rich-contenteditable__input { + animation: pulse 2s infinite; + } +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0px rgba(128, 128, 128, 0.7); + } + 70% { + box-shadow: 0 0 0 14px rgba(128, 128, 128, 0); + } + 100% { + box-shadow: 0 0 0 0px rgba(128, 128, 128, 0); + } } From e02c6d810e77c451ad2cd060a7d0d19cb5a97bf4 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 19 May 2026 10:48:57 +0200 Subject: [PATCH 13/34] start listening to notify_push messages when loading a task from a notification or in the standalone page Signed-off-by: Julien Veyssier --- src/assistant.js | 41 ++++++++++++++++++++++++++----------- src/views/AssistantPage.vue | 36 +++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/assistant.js b/src/assistant.js index aa0be253c..ff1c44282 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -632,6 +632,31 @@ export async function openAssistantTask( const view = app.mount(modalMountPoint) let lastTask = task + // notify push stuff + const isListeningTo = {} + // listen only if needed + // return true if notify_push is available + // we can't cleanup isListeningTo because there is no way to remove a handler with @nextcloud/notify_push + const listenToTaskNotifications = (pushTaskId) => { + if (isListeningTo[pushTaskId]) { + return true + } + // attempt to listen to push notifications to get the intermediate output + const pushChannel = 'task_' + pushTaskId + const hasPush = listen(pushChannel, (type, body) => { + console.debug('[assistant] received push notification', type, body) + if (pushTaskId === view.selectedTaskId) { + view.outputs = body + } else { + console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', view.selectedTaskId) + } + }) + if (hasPush) { + isListeningTo[pushTaskId] = true + } + return hasPush + } + modalMountPoint.addEventListener('cancel', () => { cancelTaskPolling() app.unmount() @@ -668,17 +693,7 @@ export async function openAssistantTask( view.selectedTaskId = lastTask?.id view.expectedRuntime = (lastTask?.completionExpectedAt - lastTask?.scheduledAt) || null - // attempt to listen to push notifications to get the intermediate output - const pushTaskId = task.id - const pushChannel = 'task_' + pushTaskId - const hasPush = listen(pushChannel, (type, body) => { - console.debug('[assistant] received push notification', type, body) - if (pushTaskId === view.selectedTaskId) { - view.outputs = body - } else { - console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', view.selectedTaskId) - } - }) + const hasPush = listenToTaskNotifications(task.id) console.debug('[assistant] HAS PUSH', hasPush) pollTask(task.id, view, !hasPush).then(finishedTask => { @@ -767,7 +782,9 @@ export async function openAssistantTask( view.progress = null view.expectedRuntime = (updatedTask?.completionExpectedAt - updatedTask?.scheduledAt) || null - pollTask(updatedTask.id, view).then(finishedTask => { + const hasPush = listenToTaskNotifications(task.id) + + pollTask(updatedTask.id, view, !hasPush).then(finishedTask => { console.debug('pollTask.then', finishedTask) if (finishedTask.status === TASK_STATUS_STRING.successful) { view.outputs = finishedTask?.output diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index 0798d5822..9ae333f90 100644 --- a/src/views/AssistantPage.vue +++ b/src/views/AssistantPage.vue @@ -70,6 +70,7 @@ export default { progress: null, loading: false, isNotifyEnabled: false, + isListeningTo: {}, } }, @@ -112,6 +113,25 @@ export default { this.task.status = null }) }, + listenToTaskNotifications(pushTaskId) { + if (this.isListeningTo[pushTaskId]) { + return true + } + // attempt to listen to push notifications to get the intermediate output + const pushChannel = 'task_' + pushTaskId + const hasPush = listen(pushChannel, (type, body) => { + console.debug('[assistant] received push notification', type, body) + if (pushTaskId === this.task.id) { + this.task.output = body + } else { + console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', this.task.id) + } + }) + if (hasPush) { + this.isListeningTo[pushTaskId] = true + } + return hasPush + }, syncSubmit(inputs, taskTypeId, newTaskIdentifier = '') { this.loading = true this.showSyncTaskRunning = true @@ -130,17 +150,7 @@ export default { this.task.completionExpectedAt = task.completionExpectedAt this.task.scheduledAt = task.scheduledAt - // attempt to listen to push notifications to get the intermediate output - const pushTaskId = task.id - const pushChannel = 'task_' + pushTaskId - const hasPush = listen(pushChannel, (type, body) => { - console.debug('[assistant] received push notification', type, body) - if (pushTaskId === this.task.id) { - this.task.output = body - } else { - console.debug('[assistant] ignoring push notification for task', pushTaskId, 'the selected one is', this.task.id) - } - }) + const hasPush = this.listenToTaskNotifications(task.id) console.debug('[assistant] HAS PUSH', hasPush) pollTask(task.id, this, !hasPush, this.updateTask).then(finishedTask => { @@ -217,7 +227,9 @@ export default { this.task.completionExpectedAt = updatedTask.completionExpectedAt this.task.scheduledAt = updatedTask.scheduledAt - pollTask(updatedTask.id, this, true, this.updateTask).then(finishedTask => { + const hasPush = this.listenToTaskNotifications(task.id) + + pollTask(updatedTask.id, this, !hasPush, this.updateTask).then(finishedTask => { console.debug('pollTask.then', finishedTask) if (finishedTask.status === TASK_STATUS_STRING.successful) { this.task.output = finishedTask?.output From 20c87fc0c99ab5543cadd67b3d7abb7ed454793e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 19 May 2026 13:41:26 +0200 Subject: [PATCH 14/34] add pulse animation to 'getting results...' label Signed-off-by: Julien Veyssier --- .../AssistantTextProcessingForm.vue | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/AssistantTextProcessingForm.vue b/src/components/AssistantTextProcessingForm.vue index 4315a27af..2d1ae2cc2 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -61,8 +61,7 @@ @@ -77,7 +76,9 @@
- {{ t('assistant', 'Getting results…') }} +