diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 2f3a7e245..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 * @@ -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/lib/Service/ChatService.php b/lib/Service/ChatService.php index 3712551f3..ac39bd837 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -653,6 +653,8 @@ private function scheduleLLMChatTask( $input['memories'] = $this->sessionSummaryService->getMemories($userId); } $task = new Task(TextToTextChat::ID, $input, Application::APP_ID . ':chatty-llm', $userId, $customId); + /** @psalm-suppress UndefinedMethod */ + $task->setPreferStreaming(true); try { $this->taskProcessingManager->scheduleTask($task); } catch (PreConditionNotMetException $e) { @@ -700,6 +702,8 @@ private function scheduleAgencyTask( $userId, $customId ); + /** @psalm-suppress UndefinedMethod */ + $task->setPreferStreaming(true); try { $this->taskProcessingManager->scheduleTask($task); } catch (PreConditionNotMetException $e) { 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" + } + ] + } } } } 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", diff --git a/src/assistant.js b/src/assistant.js index 25deadf4d..80279948d 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -3,14 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { TASK_STATUS_STRING } from './constants.js' +import { TASK_STATUS_STRING, TASK_STATUS_INT } from './constants.js' 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 +listen('taskprocessing:task_update', (type, body) => { + console.debug('[assistant] received task update push notification', type, body) + const newStatus = body.new_status + const taskId = body.task_id + if (newStatus === TASK_STATUS_INT.successful) { + // when a task successfully finished, we want to update its status AND output + // in case it is not the currently selected task + getTask(taskId).then(response => { + const task = response.data?.ocs?.data?.task + emit('assistant:task:updated', task) + }) + } else { + emit('assistant:task:status:updated', { taskId, status: newStatus }) + } +}) + /** * Creates an assistant modal and return a promise which provides the result * @@ -123,6 +140,32 @@ 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 + // TODO cleanup the handlers when we know we don't wanna listen anymore to a channel (task finished, failed...) + const listenToTaskNotifications = (pushTaskId) => { + if (isListeningTo[pushTaskId]) { + return true + } + // attempt to listen to push notifications to get the intermediate output + const pushChannel = 'taskprocessing:task_id_' + 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() @@ -138,6 +181,7 @@ export async function openAssistantForm({ view.startedAt = null view.completionExpectedAt = null view.inputs = inputs + view.outputs = null view.selectedTaskTypeId = taskTypeId scheduleTask(appId, newTaskCustomId, taskTypeId, inputs) @@ -149,7 +193,11 @@ export async function openAssistantForm({ view.startedAt = lastTask?.startedAt || null view.completionExpectedAt = lastTask?.completionExpectedAt || 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) { @@ -213,6 +261,7 @@ export async function openAssistantForm({ view.showSyncTaskRunning = false view.isNotifyEnabled = false view.loading = false + view.taskStatus = task.status view.selectedTaskTypeId = task.type view.inputs = task.input @@ -229,6 +278,7 @@ export async function openAssistantForm({ view.inputs = updatedTask.input view.outputs = updatedTask.status === TASK_STATUS_STRING.successful ? updatedTask.output : null view.selectedTaskId = updatedTask.id + view.taskStatus = updatedTask.status lastTask = updatedTask return } @@ -246,7 +296,10 @@ export async function openAssistantForm({ view.startedAt = lastTask?.startedAt || null view.completionExpectedAt = lastTask?.completionExpectedAt || 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 @@ -295,6 +348,7 @@ export async function openAssistantForm({ view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null + view.taskStatus = null lastTask = null }) modalMountPoint.addEventListener('background-notify', (data) => { @@ -309,6 +363,8 @@ export async function openAssistantForm({ view.loading = false view.showSyncTaskRunning = false view.selectedTaskId = null + view.outputs = null + view.taskStatus = null lastTask = null }) }) @@ -323,19 +379,32 @@ 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 + } object.startedAt = task?.startedAt object.completionExpectedAt = task?.completionExpectedAt } -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) { @@ -343,7 +412,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 @@ -361,7 +430,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) }) } @@ -424,6 +496,7 @@ export async function scheduleTask(appId, customId, taskType, inputs) { type: taskType, appId, customId, + preferStreaming: true, } return axios.post(url, params, { signal: window.assistantAbortController.signal }) } @@ -588,6 +661,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 = 'taskprocessing:task_id_' + 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() @@ -616,6 +714,7 @@ export async function openAssistantTask( view.startedAt = null view.completionExpectedAt = null view.inputs = inputs + view.outputs = null view.selectedTaskTypeId = taskTypeId scheduleTask('assistant', newTaskCustomId, taskTypeId, inputs) @@ -626,7 +725,10 @@ export async function openAssistantTask( view.expectedRuntime = (lastTask?.completionExpectedAt - lastTask?.scheduledAt) || null view.startedAt = lastTask?.startedAt || null view.completionExpectedAt = lastTask?.completionExpectedAt || 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) { @@ -681,6 +783,7 @@ export async function openAssistantTask( view.showSyncTaskRunning = false view.isNotifyEnabled = false view.loading = false + view.taskStatus = task.status view.selectedTaskTypeId = task.type view.inputs = task.input @@ -697,6 +800,7 @@ export async function openAssistantTask( view.inputs = updatedTask.input view.outputs = updatedTask.status === TASK_STATUS_STRING.successful ? updatedTask.output : null view.selectedTaskId = updatedTask.id + view.taskStatus = updatedTask.status lastTask = updatedTask return } @@ -714,7 +818,9 @@ export async function openAssistantTask( view.startedAt = lastTask?.startedAt || null view.completionExpectedAt = lastTask?.completionExpectedAt || 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 +869,7 @@ export async function openAssistantTask( view.isNotifyEnabled = false view.outputs = null view.selectedTaskId = null + view.taskStatus = null lastTask = null }) modalMountPoint.addEventListener('background-notify', (data) => { @@ -777,6 +884,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 d22f9f733..18ae18a88 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -42,7 +42,7 @@ + +
+ + + + {{ t('assistant', 'Get notified when the task finishes') }} + + + + {{ t('assistant', 'Cancel task') }} + +
+
@@ -153,6 +177,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' @@ -165,6 +192,7 @@ import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import NcAssistantIcon from '@nextcloud/vue/components/NcAssistantIcon' import NcPopover from '@nextcloud/vue/components/NcPopover' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import AssistantFormInputs from './AssistantFormInputs.vue' import AssistantFormOutputs from './AssistantFormOutputs.vue' @@ -176,7 +204,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,11 +235,15 @@ export default { NcAppNavigationNew, NcAssistantIcon, NcPopover, + NcNoteCard, CreationIcon, PlusIcon, UnfoldLessHorizontalIcon, UnfoldMoreHorizontalIcon, InformationBoxIcon, + BellOutlineIcon, + BellRingOutlineIcon, + CloseIcon, AssistantFormInputs, AssistantFormOutputs, ChattyLLMInputForm, @@ -220,6 +252,7 @@ export default { provide() { return { providedCurrentTaskId: () => this.selectedTaskId, + streaming: () => this.streaming, } }, props: { @@ -354,19 +387,23 @@ export default { return this.selectedTaskType }, canSubmit() { + const inputs = this.myInputs + 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) { + console.debug('[assistant] canSubmit', inputs) + if (Object.keys(inputs).length === 0) { return false } const taskType = this.selectedTaskType // check that all fields required by the task type are defined return Object.keys(taskType.inputShape).every(k => { - if (this.myInputs[k] === null || this.myInputs[k] === undefined) { + if (inputs[k] === null || inputs[k] === undefined) { return false } const fieldType = taskType.inputShape[k].type - const value = this.myInputs[k] + const value = inputs[k] return ([SHAPE_TYPE_NAMES.Text, SHAPE_TYPE_NAMES.Enum].includes(fieldType) && typeof value === 'string' && !!value?.trim() @@ -419,6 +456,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) { @@ -839,20 +882,20 @@ 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; padding-right: 0.5em; font-weight: bold; background-color: var(--color-main-background); &__title { + padding-left: 52px; display: flex; align-items: center; gap: 0.5em; @@ -862,6 +905,19 @@ export default { white-space: nowrap; } + &__subtitle { + margin-left: 22px; + align-self: start; + .subtitle-content { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + width: 100%; + padding: 4px 0 4px 0px; + } + } + &__provider { font-weight: normal; font-size: 0.9em; diff --git a/src/components/AssistantTextProcessingModal.vue b/src/components/AssistantTextProcessingModal.vue index 9c543e025..3b05f661f 100644 --- a/src/components/AssistantTextProcessingModal.vue +++ b/src/components/AssistantTextProcessingModal.vue @@ -221,7 +221,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 13833ca63..d407e703d 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -97,7 +97,11 @@
-
+
@@ -149,6 +153,7 @@
{ + this.$refs.inputComponent.focus() + if (!this.isAssignment) { + this.$refs.inputComponent.focus() + } + }) + }, + scrollToLastMessage() { console.debug('scrollToBottom: active:', this.active) if (this.active == null) { return @@ -545,9 +578,25 @@ export default { this.$nextTick(() => { const lastIdx = this.messages.length - 1 document.querySelector('#message' + lastIdx)?.scrollIntoView() - if (!this.isAssignment) { - this.$refs.inputComponent.focus() - } + document.querySelector('#message-streaming')?.scrollIntoView() + document.querySelector('#message-placeholder')?.scrollIntoView() + }) + this.focusOnInputField() + }, + scrollToBottomWhileStreaming() { + if (this.active == null) { + return + } + if (this.messages == null) { + return + } + if (this.userScrolled) { + return + } + + this.$nextTick(() => { + const chatAreaElem = this.$refs.chatArea + chatAreaElem.scrollTop = chatAreaElem.scrollHeight }) }, @@ -632,7 +681,7 @@ export default { this.messages.push({ role, content, timestamp, session_id: this.active.id }) this.chatContent = '' - this.scrollToBottom() + this.scrollToLastMessage() await this.newMessage(role, content, timestamp, this.active.id) }, @@ -655,7 +704,7 @@ export default { this.messages.push({ role, content, timestamp, session_id: this.active.id, attachments }) this.chatContent = '' - this.scrollToBottom() + this.scrollToLastMessage() await this.newMessage(role, content, timestamp, this.active.id, attachments) }, @@ -864,9 +913,11 @@ export default { async runGenerationTask(sessionId, agencyConfirm = null) { try { + this.scrollToLastMessage() this.slowPickup = false this.loading.llmGeneration = true this.loading.llmRunning = false + this.userScrolled = false const params = { sessionId, } @@ -880,13 +931,19 @@ export default { const message = await this.pollGenerationTask(generationResponseData.taskId, sessionId) console.debug('checkTaskPolling result:', message) this.messages.push(message) - this.scrollToBottom() + if (this.streamingMessage === null) { + this.scrollToLastMessage() + } else { + this.focusOnInputField() + } } catch (error) { console.error('scheduleGenerationTask error:', error) showError(t('assistant', 'Error generating a response')) } finally { this.loading.llmGeneration = false this.loading.llmRunning = false + this.streamingMessage = null + this.userScrolled = false } }, @@ -895,23 +952,62 @@ export default { const sessionId = this.active.id this.loading.llmGeneration = true this.loading.llmRunning = false + this.userScrolled = false const regenerationResponse = await axios.get(getChatURL('/regenerate'), { params: { messageId, sessionId } }) const regenerationResponseData = regenerationResponse.data console.debug('scheduleRegenerationTask response:', regenerationResponse) const message = await this.pollGenerationTask(regenerationResponseData.taskId, sessionId) console.debug('checkTaskPolling result:', message) this.messages[this.messages.length - 1] = message - this.scrollToBottom() + if (this.streamingMessage === null) { + this.scrollToLastMessage() + } else { + this.focusOnInputField() + } } catch (error) { console.error('scheduleRegenerationTask error:', error) showError(t('assistant', 'Error regenerating a response')) } finally { this.loading.llmGeneration = false this.loading.llmRunning = false + this.streamingMessage = null + this.userScrolled = false } }, + listenToTaskNotifications(pushTaskId, pushSessionId) { + // attempt to listen to push notifications to get the intermediate output + if (this.isListeningTo[pushTaskId]) { + return true + } + const pushChannel = 'taskprocessing:task_id_' + 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 ?? {}, 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) { @@ -956,12 +1052,33 @@ export default { if (error.response.data.task_status === TASK_STATUS_INT.running) { this.loading.llmRunning = true } + if (!hasPush && typeof error.response.data.task_output !== 'undefined' && error.response.data.task_output !== null) { + this.updateStreamingMessage(error.response.data.task_output || {}, sessionId) + } } }) }, 2000) }) }, + updateStreamingMessage({ output, sources }, sessionId) { + if (this.streamingMessage) { + this.streamingMessage.content = output + this.streamingMessage.sources = sources + } else { + this.streamingMessage = { + role: Roles.ASSISTANT, + content: output, + attachments: [], + sources, + session_id: sessionId, + id: 0, + timestamp: moment().unix(), + } + } + this.scrollToBottomWhileStreaming() + }, + getLastHumanMessage() { return this.messages .filter(m => m.role === Roles.HUMAN) @@ -1045,7 +1162,7 @@ export default { // this.messages.push({ role, content, timestamp }) this.chatContent = '' - this.scrollToBottom() + this.scrollToLastMessage() await this.newMessage(role, content, timestamp, this.active.id, null, false, confirm) }, diff --git a/src/components/ChattyLLM/ConversationBox.vue b/src/components/ChattyLLM/ConversationBox.vue index c73e03089..fd1130ad6 100644 --- a/src/components/ChattyLLM/ConversationBox.vue +++ b/src/components/ChattyLLM/ConversationBox.vue @@ -31,7 +31,16 @@ :information-source-names="informationSourceNames" @regenerate="regenerate(message.id)" @delete="deleteMessage(message.id)" /> - + +
@@ -83,6 +92,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 67a796d08..a40c24c47 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -196,6 +196,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 236164954..76ec54527 100644 --- a/src/components/ChattyLLM/Message.vue +++ b/src/components/ChattyLLM/Message.vue @@ -3,7 +3,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->