You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

293 lines
7.7 KiB

<!--
- 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"
variant="tertiary"
:title="!collapsed ? t('spreed', 'Collapse') : t('spreed', 'Expand')"
:aria-label="!collapsed ? t('spreed', 'Collapse') : t('spreed', 'Expand')"
@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>
<div class="chat-summary__actions">
<NcButton v-if="loading"
class="chat-summary__action"
variant="primary"
:disabled="cancelling"
@click="cancelSummary">
<template v-if="cancelling" #icon>
<NcLoadingIcon />
</template>
{{ t('spreed', 'Cancel') }}
</NcButton>
<NcButton v-else-if="chatSummaryMessage"
class="chat-summary__action"
variant="primary"
@click="dismissSummary">
{{ t('spreed', 'Dismiss') }}
</NcButton>
</div>
</NcNoteCard>
</template>
<script setup lang="ts">
import type { SummarizeChatTask, TaskProcessingResponse } from '../../types/index.ts'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import IconChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import IconMessageBulleted from 'vue-material-design-icons/MessageBulleted.vue'
import { useGetToken } from '../../composables/useGetToken.ts'
import { useStore } from '../../composables/useStore.js'
import { TASK_PROCESSING } from '../../constants.ts'
import { deleteTaskById, getTaskById } from '../../services/coreService.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.js'
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 cancelling = ref(false)
const store = useStore()
const chatExtrasStore = useChatExtrasStore()
const token = useGetToken()
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
showError(t('spreed', 'Error occurred during a summary generation'))
cancelSummary()
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 dismissSummary() {
Object.values(cancelGetTask).forEach((cancelFn) => cancelFn())
clearInterval(getTaskInterval)
getTaskInterval = undefined
chatExtrasStore.dismissChatSummary(token.value)
}
/**
*
*/
async function cancelSummary() {
cancelling.value = true
const taskQueue: ChatTask[] = chatExtrasStore.getChatSummaryTaskQueue(token.value)
for await (const task of taskQueue) {
await deleteTaskById(task.taskId)
}
cancelling.value = false
dismissSummary()
}
/**
*
*/
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-inline-start: 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;
}
}
&__actions {
display: flex;
justify-content: flex-end;
gap: var(--default-grid-baseline);
z-index: 1;
}
&__button {
position: absolute !important;
top: var(--default-grid-baseline);
inset-inline-end: calc(5 * var(--default-grid-baseline));
z-index: 1;
& .icon {
transition: $transition;
&--reverted {
transform: rotate(180deg);
}
}
}
}
</style>