Skip to content

Commit

Permalink
feat: request tasks and show summary
Browse files Browse the repository at this point in the history
Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
  • Loading branch information
Antreesy authored and backportbot[bot] committed Nov 22, 2024
1 parent 30c65b5 commit a748428
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 1 deletion.
8 changes: 8 additions & 0 deletions src/components/NewMessage/NewMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
:user-absence="userAbsence"
:display-name="conversation.displayName" />

<NewMessageChatSummary v-if="showChatSummary" />

<div class="new-message-form__emoji-picker">
<NcEmojiPicker v-if="!disabled"
:close-on-select="false"
Expand Down Expand Up @@ -201,6 +203,7 @@ import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
import NewMessageAbsenceInfo from './NewMessageAbsenceInfo.vue'
import NewMessageAttachments from './NewMessageAttachments.vue'
import NewMessageAudioRecorder from './NewMessageAudioRecorder.vue'
import NewMessageChatSummary from './NewMessageChatSummary.vue'
import NewMessageNewFileDialog from './NewMessageNewFileDialog.vue'
import NewMessagePollEditor from './NewMessagePollEditor.vue'
import NewMessageTypingIndicator from './NewMessageTypingIndicator.vue'
Expand Down Expand Up @@ -234,6 +237,7 @@ export default {
NewMessageAbsenceInfo,
NewMessageAttachments,
NewMessageAudioRecorder,
NewMessageChatSummary,
NewMessageNewFileDialog,
NewMessagePollEditor,
PollDraftHandler,
Expand Down Expand Up @@ -466,6 +470,10 @@ export default {
return this.chatExtrasStore.absence[this.token]
},

showChatSummary() {
return this.chatExtrasStore.hasChatSummaryTaskRequested(this.token)
},

isMobileDevice() {
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
},
Expand Down
2 changes: 2 additions & 0 deletions src/components/NewMessage/NewMessageAbsenceInfo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ export default {
&__message {
white-space: pre-line;
word-wrap: break-word;
max-height: 30vh;
overflow: auto;

&--collapsed {
text-overflow: ellipsis;
Expand Down
247 changes: 247 additions & 0 deletions src/components/NewMessage/NewMessageChatSummary.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<!-- eslint-disable vue/multiline-html-element-content-newline -->
<template>
<NcNoteCard type="info" class="chat-summary">
<template #icon>
<NcLoadingIcon v-if="loading" />
<IconMessageBulleted v-else />
</template>
<NcButton v-if="isTextMoreThanOneLine"
class="chat-summary__button"
type="tertiary"
@click="toggleCollapsed">
<template #icon>
<IconChevronUp class="icon" :class="{'icon--reverted': !collapsed}" :size="20" />
</template>
</NcButton>
<template v-if="loading">
<p class="chat-summary__caption">
{{ t('spreed', 'Generating summary of unread messages ...') }}
</p>
<p>{{ t('spreed', 'This might take a moment') }}</p>
</template>
<template v-else>
<p class="chat-summary__caption">
{{ t('spreed', 'Summary is AI generated and might contain mistakes') }}
</p>
<p ref="chatSummaryRef"
class="chat-summary__message"
:class="{'chat-summary__message--collapsed': collapsed}">{{ chatSummaryMessage }}</p>
</template>

</NcNoteCard>
</template>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

import IconChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import IconMessageBulleted from 'vue-material-design-icons/MessageBulleted.vue'

import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'

import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'

import { useStore } from '../../composables/useStore.js'
import { TASK_PROCESSING } from '../../constants.js'
import { getTaskById } from '../../services/coreService.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.js'
import type { TaskProcessingResponse, SummarizeChatTask } from '../../types/index.ts'
import CancelableRequest from '../../utils/cancelableRequest.js'

type TaskProcessingCancelableRequest = {
request: (taskId: number) => TaskProcessingResponse,
cancel: () => void,
}

type ChatTask = SummarizeChatTask & {
fromMessageId: number,
summary?: string
}

let getTaskInterval: NodeJS.Timeout | undefined
const cancelGetTask: Record<string, TaskProcessingCancelableRequest['cancel']> = {}

const chatSummaryRef = ref(null)
const collapsed = ref(true)
const isTextMoreThanOneLine = ref(false)

const loading = ref(true)

const store = useStore()
const chatExtrasStore = useChatExtrasStore()

const token = computed(() => store.getters.getToken())
const chatSummaryMessage = ref('')

watch(chatSummaryMessage, () => {
nextTick(() => {
setIsTextMoreThanOneLine()
})
}, { immediate: true })

onBeforeUnmount(() => {
Object.values(cancelGetTask).forEach(cancelFn => cancelFn())
})

watch(token, (newValue, oldValue) => {
// Cancel pending requests when leaving room
if (oldValue && cancelGetTask[oldValue]) {
cancelGetTask[oldValue]?.()
clearInterval(getTaskInterval)
getTaskInterval = undefined
}
if (newValue) {
loading.value = true
chatSummaryMessage.value = ''
checkScheduledTasks(newValue)
}
}, { immediate: true })

/**
*
* @param token conversation token
*/
function checkScheduledTasks(token: string) {
const taskQueue: ChatTask[] = chatExtrasStore.getChatSummaryTaskQueue(token)

if (!taskQueue.length) {
return
}

for (const task of taskQueue) {
if (task.summary) {
// Task is already finished, checking next one
continue
}
const { request, cancel } = CancelableRequest(getTaskById) as TaskProcessingCancelableRequest
cancelGetTask[token] = cancel

getTaskInterval = setInterval(() => {
getTask(token, request, task)
}, 5000)
return
}

// There was no return, so checking all tasks are finished
chatSummaryMessage.value = chatExtrasStore.getChatSummary(token)
loading.value = false
}

/**
*
* @param token conversation token
* @param request cancelable request to get task from API
* @param task task object
*/
async function getTask(token: string, request: TaskProcessingCancelableRequest['request'], task: ChatTask) {
try {
const response = await request(task.taskId)
const status = response.data.ocs.data.task.status
switch (status) {
case TASK_PROCESSING.STATUS.SUCCESSFUL: {
// Task is completed, proceed to the next task
const summary = response.data.ocs.data.task.output?.output || ''
chatExtrasStore.storeChatSummary(token, task.fromMessageId, summary)
clearInterval(getTaskInterval)
getTaskInterval = undefined
checkScheduledTasks(token)
break
}
case TASK_PROCESSING.STATUS.FAILED:
case TASK_PROCESSING.STATUS.UNKNOWN:
case TASK_PROCESSING.STATUS.CANCELLED: {
// Task is likely failed, proceed to the next task
chatExtrasStore.storeChatSummary(token, task.fromMessageId, '')
showError(t('spreed', 'Error occurred during a summary generation'))
clearInterval(getTaskInterval)
getTaskInterval = undefined
checkScheduledTasks(token)
break
}
case TASK_PROCESSING.STATUS.SCHEDULED:
case TASK_PROCESSING.STATUS.RUNNING:
default: {
// Task is still processing, scheduling next request
break
}
}
} catch (error) {
if (CancelableRequest.isCancel(error)) {
return
}
console.error('Error getting chat summary:', error)
}
}

/**
*
*/
function toggleCollapsed() {
collapsed.value = !collapsed.value
}

/**
*
*/
function setIsTextMoreThanOneLine() {
// @ts-expect-error: template ref typing
isTextMoreThanOneLine.value = chatSummaryRef.value?.scrollHeight > chatSummaryRef.value?.clientHeight
}
</script>

<style lang="scss" scoped>
@import '../../assets/variables';

.chat-summary {
// Override NcNoteCard styles
margin: 0 calc(var(--default-grid-baseline) * 4) calc(var(--default-grid-baseline) * 2) !important;
padding: calc(var(--default-grid-baseline) * 2) !important;
& > :deep(div) {
width: 100%;
}

&__caption {
font-weight: bold;
margin: var(--default-grid-baseline) var(--default-clickable-area);
margin-left: 0;
}

&__message {
white-space: pre-line;
word-wrap: break-word;
max-height: 30vh;
overflow: auto;

&--collapsed {
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
}

&__button {
position: absolute !important;
top: var(--default-grid-baseline);
right: calc(5 * var(--default-grid-baseline));
z-index: 1;

& .icon {
transition: $transition;

&--reverted {
transform: rotate(180deg);
}
}
}
}
</style>
14 changes: 13 additions & 1 deletion src/stores/chatExtras.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { defineStore } from 'pinia'
import Vue from 'vue'

import { t } from '@nextcloud/l10n'
import { generateUrl, getBaseUrl } from '@nextcloud/router'

import BrowserStorage from '../services/BrowserStorage.js'
Expand Down Expand Up @@ -71,6 +72,11 @@ export const useChatExtrasStore = defineStore('chatExtras', {
hasChatSummaryTaskRequested: (state) => (token) => {
return state.chatSummary[token] !== undefined
},

getChatSummary: (state) => (token) => {
return Object.values(Object(state.chatSummary[token])).map(task => task.summary).join('\n\n')
|| t('spreed', 'Error occurred during a summary generation')
},
},

actions: {
Expand Down Expand Up @@ -280,6 +286,12 @@ export const useChatExtrasStore = defineStore('chatExtras', {
} catch (error) {
console.error('Error while requesting a summary:', error)
}
}
},

storeChatSummary(token, fromMessageId, summary) {
if (this.chatSummary[token][fromMessageId]) {
Vue.set(this.chatSummary[token][fromMessageId], 'summary', summary)
}
},
},
})

0 comments on commit a748428

Please sign in to comment.