diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 8a6aac36..6804e8a0 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -548,7 +548,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 * @@ -599,7 +599,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/openapi.json b/openapi.json index 7bd328db..a081df19 100644 --- a/openapi.json +++ b/openapi.json @@ -4852,6 +4852,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" + } + ] + } } } } diff --git a/package-lock.json b/package-lock.json index 4d4f9c7c..ffcbcf1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "assistant", - "version": "3.2.0", + "version": "3.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "assistant", - "version": "3.2.0", + "version": "3.4.1", "license": "AGPL-3.0", "dependencies": { "@breezystack/lamejs": "^1.2.7", @@ -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", @@ -2870,27 +2871,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" @@ -3172,6 +3173,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", @@ -3520,9 +3532,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3545,9 +3554,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3570,9 +3576,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3595,9 +3598,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3620,9 +3620,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3645,9 +3642,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3969,9 +3963,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3987,9 +3978,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4005,9 +3993,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4023,9 +4008,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4041,9 +4023,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4059,9 +4038,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4077,9 +4053,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4095,9 +4068,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4113,9 +4083,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4131,9 +4098,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4149,9 +4113,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4167,9 +4128,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4185,9 +4143,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4991,9 +4946,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5009,9 +4961,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5027,9 +4976,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5045,9 +4991,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5063,9 +5006,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5081,9 +5021,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5099,9 +5036,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5117,9 +5051,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5939,14 +5870,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": { @@ -8747,9 +8678,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", @@ -12377,10 +12308,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 e5fa2556..723af200 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", diff --git a/src/assistant.js b/src/assistant.js index d5b10180..11fe46ea 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 @@ -123,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 ?? null + } 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() @@ -136,6 +162,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) @@ -145,7 +172,11 @@ export async function openAssistantForm({ view.selectedTaskId = lastTask?.id view.expectedRuntime = (lastTask?.completionExpectedAt - lastTask?.scheduledAt) || null - pollTask(task.id, view).then(finishedTask => { + 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 + pollTask(task.id, view, !hasPush).then(finishedTask => { console.debug('pollTask.then', finishedTask) if (finishedTask.status === TASK_STATUS_STRING.successful) { if (closeOnResult) { @@ -240,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 @@ -303,6 +337,8 @@ export async function openAssistantForm({ view.loading = false view.showSyncTaskRunning = false view.selectedTaskId = null + view.outputs = null + view.taskStatus = null lastTask = null }) }) @@ -317,17 +353,30 @@ 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 + 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) => { - window.assistantPollTimerId = setInterval(() => { + const pollOnce = () => { getTask(taskId).then(response => { const task = response.data?.ocs?.data?.task if (window.assistantPollTimerId === null) { @@ -335,7 +384,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 @@ -353,7 +402,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) }) } @@ -580,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 ?? null + } 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() @@ -606,6 +683,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) @@ -614,7 +692,11 @@ export async function openAssistantTask( lastTask = task view.selectedTaskId = lastTask?.id view.expectedRuntime = (lastTask?.completionExpectedAt - lastTask?.scheduledAt) || null - pollTask(task.id, view).then(finishedTask => { + + const hasPush = listenToTaskNotifications(task.id) + 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) { @@ -700,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 @@ -763,6 +847,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 7f35482b..0fe2e7b4 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -39,7 +39,7 @@ +
+ + + + {{ t('assistant', 'Get notified when the task finishes') }} + + + + {{ t('assistant', 'Cancel task') }} + +
@@ -148,6 +169,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' @@ -171,7 +195,7 @@ import TaskList from './TaskList.vue' import TaskTypeSelect from './TaskTypeSelect.vue' import TranslateForm from './Translate/TranslateForm.vue' -import { SHAPE_TYPE_NAMES, MAX_TEXT_INPUT_LENGTH } from '../constants.js' +import { SHAPE_TYPE_NAMES, MAX_TEXT_INPUT_LENGTH, TASK_STATUS_STRING } from '../constants.js' import axios from '@nextcloud/axios' import { generateOcsUrl, generateUrl } from '@nextcloud/router' @@ -207,6 +231,9 @@ export default { UnfoldLessHorizontalIcon, UnfoldMoreHorizontalIcon, InformationBoxIcon, + BellOutlineIcon, + BellRingOutlineIcon, + CloseIcon, AssistantFormInputs, AssistantFormOutputs, ChattyLLMInputForm, @@ -215,6 +242,7 @@ export default { provide() { return { providedCurrentTaskId: () => this.selectedTaskId, + streaming: () => this.streaming, } }, props: { @@ -341,6 +369,9 @@ export default { return this.selectedTaskType }, canSubmit() { + if (this.taskStatus === TASK_STATUS_STRING.running) { + return false + } // otherwise, check that none of the properties of myInputs are empty console.debug('[assistant] canSubmit', this.myInputs) if (Object.keys(this.myInputs).length === 0) { @@ -406,6 +437,12 @@ export default { actionButtonsToShow() { return this.hasOutput ? this.actionButtons : [] }, + showRunningEmptyContent() { + return this.showSyncTaskRunning && this.myOutputs === null + }, + streaming() { + return this.showSyncTaskRunning && this.myOutputs !== null + }, }, watch: { outputs(newVal) { @@ -822,12 +859,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; @@ -845,6 +882,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/components/AssistantTextProcessingModal.vue b/src/components/AssistantTextProcessingModal.vue index 58a370e3..3edd6b20 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)); diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 7c11ef18..0ce0e5ef 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -118,6 +118,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() }) }, @@ -716,6 +724,7 @@ export default { async runGenerationTask(sessionId, agencyConfirm = null) { try { + this.scrollToBottom() this.slowPickup = false this.loading.llmGeneration = true const params = { @@ -737,6 +746,7 @@ export default { showError(t('assistant', 'Error generating a response')) } finally { this.loading.llmGeneration = false + this.streamingMessage = null } }, @@ -756,10 +766,43 @@ export default { showError(t('assistant', 'Error regenerating a response')) } finally { this.loading.llmGeneration = false + this.streamingMessage = null + } + }, + + listenToTaskNotifications(pushTaskId, pushSessionId) { + // attempt to listen to push notifications to get the intermediate output + if (this.isListeningTo[pushTaskId]) { + return true + } + const pushChannel = 'task_' + pushTaskId + const hasPush = listen(pushChannel, (type, body) => { + console.debug('[assistant] received push notification', type, body) + const activeSessionId = this.active?.id + if (pushSessionId === activeSessionId) { + this.updateStreamingMessage(body?.output ?? '', pushSessionId) + } else { + console.debug( + '[assistant] ignoring push notification for task', + pushTaskId, + 'in session', + pushSessionId, + 'the selected session is', + this.active?.id, + ) + } + + }) + if (hasPush) { + this.isListeningTo[pushTaskId] = true } + return hasPush }, async pollGenerationTask(taskId, sessionId) { + const hasPush = this.listenToTaskNotifications(taskId, sessionId) + console.debug('[assistant] HAS PUSH', hasPush) + return new Promise((resolve, reject) => { this.pollMessageGenerationTimerId = setInterval(() => { if (this.active === null || sessionId !== this.active.id) { @@ -801,12 +844,31 @@ export default { } else { console.debug('checkTaskPolling, task is still scheduled or running') this.slowPickup = error.response.data.slow_pickup + if (error.response.data.task_output?.output) { + this.updateStreamingMessage(error.response.data.task_output.output, sessionId) + } } }) }, 2000) }) }, + 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) diff --git a/src/components/ChattyLLM/ConversationBox.vue b/src/components/ChattyLLM/ConversationBox.vue index c73e0308..20e8f7fc 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/InputArea.vue b/src/components/ChattyLLM/InputArea.vue index 95941711..72ce04dc 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -188,6 +188,11 @@ export default { font-style: italic; animation: breathing 2s linear infinite normal; } + @media (prefers-reduced-motion: reduce) { + :deep(&__thinking > div) { + animation: none; + } + } &__button-box { display: flex; diff --git a/src/components/ChattyLLM/Message.vue b/src/components/ChattyLLM/Message.vue index 23616495..b9f09763 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, diff --git a/src/components/fields/AudioRecorderWrapper.vue b/src/components/fields/AudioRecorderWrapper.vue index 8862d83a..dddb091b 100644 --- a/src/components/fields/AudioRecorderWrapper.vue +++ b/src/components/fields/AudioRecorderWrapper.vue @@ -263,7 +263,7 @@ export default { height: 16px; flex: 0 0 16px; border-radius: 8px; - background-color: var(--color-error); + background-color: var(--color-element-error); } @keyframes fadeOutIn { @@ -271,7 +271,7 @@ export default { opacity: 1; } 50% { - opacity: .3; + opacity: 0.1; } 100% { opacity: 1; @@ -281,6 +281,11 @@ export default { .fadeOutIn { animation: fadeOutIn 3s infinite; } + @media (prefers-reduced-motion: reduce) { + .fadeOutIn { + animation: none; + } + } } } diff --git a/src/components/fields/TextInput.vue b/src/components/fields/TextInput.vue index c88045ee..8372ce86 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)" @@ -28,10 +28,16 @@ :title="t('assistant', 'Copy output')" @click="onCopy"> - {{ t('assistant', 'Copy') }} + + {{ t('assistant', 'Getting results...') }} + + + {{ t('assistant', 'Copy') }} + diff --git a/src/views/AssistantPage.vue b/src/views/AssistantPage.vue index 66513129..1ee3b7eb 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, @@ -69,6 +70,7 @@ export default { progress: null, loading: false, isNotifyEnabled: false, + isListeningTo: {}, } }, @@ -96,26 +98,60 @@ export default { methods: { onBackgroundNotify(enable) { - setNotifyReady(this.task.id, enable).then(res => { - this.isNotifyEnabled = enable - }) + if (this.task?.id) { + setNotifyReady(this.task.id, enable).then(res => { + this.isNotifyEnabled = enable + }) + } }, onCancel() { cancelTaskPolling() - setNotifyReady(this.task.id, false) - cancelTask(this.task.id).then(res => { + if (this.task?.id) { + setNotifyReady(this.task.id, false) + cancelTask(this.task.id).then(res => { + this.loading = false + this.showSyncTaskRunning = false + this.task.id = null + this.task.output = null + this.task.status = null + }) + } else { + // if we ever end up in this state, this helps to recover this.loading = false this.showSyncTaskRunning = false this.task.id = null + this.task.output = null + 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 ?? null + } 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 this.isNotifyEnabled = false this.progress = null this.task.completionExpectedAt = null this.task.scheduledAt = null this.task.input = inputs + this.task.output = null this.task.type = taskTypeId scheduleTask('assistant', this.task.identifier, taskTypeId, inputs) .then((response) => { @@ -124,7 +160,11 @@ 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 => { + + const hasPush = this.listenToTaskNotifications(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) { @@ -157,11 +197,16 @@ export default { .then(() => { }) }, - updateTask(task) { + updateTask(task, _obj, updateOutput = true) { if (task.status === TASK_STATUS_STRING.running) { this.progress = task.progress } - this.task = task + this.task = updateOutput + ? task + : { + ...task, + output: this.task.output, + } }, onSyncSubmit(data) { this.syncSubmit(data.inputs, data.selectedTaskTypeId, this.task.identifier) @@ -185,7 +230,6 @@ export default { const updatedTask = response.data?.ocs?.data?.task if (![TASK_STATUS_STRING.scheduled, TASK_STATUS_STRING.running].includes(updatedTask?.status)) { - this.selectedTaskTypeId = updatedTask.type this.task.input = updatedTask.input this.task.output = updatedTask.status === TASK_STATUS_STRING.successful ? updatedTask.output : null this.task.id = updatedTask.id @@ -198,7 +242,9 @@ export default { this.task.completionExpectedAt = updatedTask.completionExpectedAt this.task.scheduledAt = updatedTask.scheduledAt - pollTask(updatedTask.id, this, 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