From 9abf22946c115bc2d1769cc2bceeecdda66fc43e Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 18 Nov 2024 12:09:41 +0100 Subject: [PATCH 1/5] fix: refactor unread marker styles Signed-off-by: Maksim Sukharev --- .../MessagesGroup/Message/Message.spec.js | 4 +- .../MessagesGroup/Message/Message.vue | 42 +++++++++++++------ src/components/MessagesList/MessagesList.vue | 2 +- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js index 30522a0e078..a809b1473c4 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js +++ b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js @@ -522,7 +522,7 @@ describe('Message.vue', () => { provide: injected, }) - const marker = wrapper.find('.new-message-marker') + const marker = wrapper.find('.message-unread-marker') expect(marker.exists()).toBe(true) expect(IntersectionObserver).toHaveBeenCalled() @@ -559,7 +559,7 @@ describe('Message.vue', () => { provide: injected, }) - const marker = wrapper.find('.new-message-marker') + const marker = wrapper.find('.message-unread-marker') expect(marker.exists()).toBe(false) }) }) diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.vue b/src/components/MessagesList/MessagesGroup/Message/Message.vue index 853673af693..aa8b7894540 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.vue +++ b/src/components/MessagesList/MessagesGroup/Message/Message.vue @@ -79,8 +79,10 @@
- {{ t('spreed', 'Unread messages') }} + class="message-unread-marker"> +
+ {{ t('spreed', 'Unread messages') }} +
@@ -467,23 +469,37 @@ export default { 100% { background-color: rgba(var(--color-background-hover), 0); } } -.new-message-marker { +.message-unread-marker { position: relative; - margin: 20px 15px; - border-top: 1px solid var(--color-border); + margin: calc(4 * var(--default-grid-baseline)); - span { + &::before { + content: ''; + width: 100%; + border-top: 1px solid var(--color-border-maxcontrast); position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%) translateY(-50%); - padding: 0 7px 0 7px; - text-align: center; - white-space: nowrap; - color: var(--color-text-light); + top: 50%; + z-index: -1; + } + + &__wrapper { + display: flex; + justify-content: center; + align-items: center; + gap: calc(3 * var(--default-grid-baseline)); + margin-inline: auto; + padding-inline: calc(3 * var(--default-grid-baseline)); + width: fit-content; border-radius: var(--border-radius); background-color: var(--color-main-background); } + + &__text { + text-align: center; + white-space: nowrap; + font-weight: bold; + color: var(--color-main-text); + } } .message-buttons-bar { diff --git a/src/components/MessagesList/MessagesList.vue b/src/components/MessagesList/MessagesList.vue index 1ca0603af13..acab13f7de5 100644 --- a/src/components/MessagesList/MessagesList.vue +++ b/src/components/MessagesList/MessagesList.vue @@ -1015,7 +1015,7 @@ export default { // e.g: it is the last message in collapsed group // unread marker is set to the combined system message. // Look for the unread marker itself - el = document.querySelector('.new-message-marker') + el = document.querySelector('.message-unread-marker') if (el) { el = el.closest('.message') } else { From 30a647096dbbb9383300f8852156313fe20dfcc3 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 18 Nov 2024 12:13:24 +0100 Subject: [PATCH 2/5] fix: add 'Generate summary' button Signed-off-by: Maksim Sukharev --- .../MessagesGroup/Message/Message.vue | 47 ++++++++++++++----- src/services/messagesService.ts | 16 +++++++ src/stores/chatExtras.js | 34 ++++++++++++++ src/types/index.ts | 2 + 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.vue b/src/components/MessagesList/MessagesGroup/Message/Message.vue index aa8b7894540..999689b72d6 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.vue +++ b/src/components/MessagesList/MessagesGroup/Message/Message.vue @@ -60,8 +60,8 @@ :title="t('spreed', 'Show or collapse system messages')" @click="toggleCombinedSystemMessage"> @@ -82,6 +82,13 @@ class="message-unread-marker">
{{ t('spreed', 'Unread messages') }} + + + {{ t('spreed', 'Generate summary') }} +
@@ -90,8 +97,9 @@ diff --git a/src/services/messagesService.ts b/src/services/messagesService.ts index 2387efb8a5f..1650f1834ba 100644 --- a/src/services/messagesService.ts +++ b/src/services/messagesService.ts @@ -26,6 +26,8 @@ import type { receiveMessagesResponse, setReadMarkerParams, setReadMarkerResponse, + summarizeChatParams, + summarizeChatResponse, } from '../types/index.ts' type ReceiveMessagesPayload = Partial & { token: string } @@ -209,6 +211,19 @@ const setConversationUnread = async function(token: string, options?: object): m return axios.delete(generateOcsUrl('apps/spreed/api/v1/chat/{token}/read', { token }, options), options) } +/** + * Request chat summary from a given message + * + * @param token The conversation token + * @param fromMessageId The last read message to start from + * @param options object destructured + */ +const summarizeChat = async function(token: string, fromMessageId: summarizeChatParams['fromMessageId'], options?: object): summarizeChatResponse { + return axios.post(generateOcsUrl('apps/spreed/api/v1/chat/{token}/summarize', { token }, options), { + fromMessageId, + } as summarizeChatParams, options) +} + export { fetchMessages, lookForNewMessages, @@ -220,4 +235,5 @@ export { postRichObjectToConversation, updateLastReadMessage, setConversationUnread, + summarizeChat, } diff --git a/src/stores/chatExtras.js b/src/stores/chatExtras.js index d2c097145c7..e022fd6c61c 100644 --- a/src/stores/chatExtras.js +++ b/src/stores/chatExtras.js @@ -11,6 +11,7 @@ import { generateUrl, getBaseUrl } from '@nextcloud/router' import BrowserStorage from '../services/BrowserStorage.js' import { getUpcomingEvents } from '../services/conversationsService.js' import { EventBus } from '../services/EventBus.ts' +import { summarizeChat } from '../services/messagesService.ts' import { getUserAbsence } from '../services/participantsService.js' import { parseSpecialSymbols, parseMentions } from '../utils/textParse.ts' @@ -41,6 +42,7 @@ export const useChatExtrasStore = defineStore('chatExtras', { chatEditInput: {}, tasksCount: 0, tasksDoneCount: 0, + chatSummary: {}, }), getters: { @@ -61,6 +63,14 @@ export const useChatExtrasStore = defineStore('chatExtras', { getNextEvent: (state) => (token) => { return state.upcomingEvents[token]?.[0] }, + + getChatSummaryTaskQueue: (state) => (token) => { + return Object.values(Object(state.chatSummary[token])) + }, + + hasChatSummaryTaskRequested: (state) => (token) => { + return state.chatSummary[token] !== undefined + }, }, actions: { @@ -246,6 +256,30 @@ export const useChatExtrasStore = defineStore('chatExtras', { setTasksCounters({ tasksCount, tasksDoneCount }) { this.tasksCount = tasksCount this.tasksDoneCount = tasksDoneCount + }, + + async requestChatSummary(token, fromMessageId) { + try { + const response = await summarizeChat(token, fromMessageId) + if (!response.data) { + console.warn('No messages found to summarize:', { token, fromMessageId }) + return + } + const task = response.data.ocs.data + + if (!this.chatSummary[token]) { + Vue.set(this.chatSummary, token, {}) + } + Vue.set(this.chatSummary[token], fromMessageId, { + ...task, + fromMessageId, + }) + if (task.nextOffset && task.nextOffset !== fromMessageId) { + await this.requestChatSummary(token, task.nextOffset) + } + } catch (error) { + console.error('Error while requesting a summary:', error) + } } }, }) diff --git a/src/types/index.ts b/src/types/index.ts index b4a16c22169..8e2a819c8fa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -96,6 +96,8 @@ export type postRichObjectResponse = ApiResponse['requestBody']['content']['application/json'] export type setReadMarkerResponse = ApiResponse export type markUnreadResponse = ApiResponse +export type summarizeChatParams = operations['chat-summarize-chat']['requestBody']['content']['application/json'] +export type summarizeChatResponse = ApiResponse // Avatars export type setFileAvatarResponse = ApiResponse From 30c65b556d632582ecc833b6f3aa0a0bb8c085ac Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 21 Nov 2024 16:24:02 +0100 Subject: [PATCH 3/5] fix: add task processing endpoint Signed-off-by: Maksim Sukharev --- src/constants.js | 14 ++++++++++++++ src/services/coreService.ts | 6 ++++++ src/types/index.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/constants.js b/src/constants.js index 70bc1e9db4e..126604ef5a2 100644 --- a/src/constants.js +++ b/src/constants.js @@ -324,3 +324,17 @@ export const MENTION = { GROUP: 'group', }, } + +/** + * Task statuses for OCP\TaskProcessing + */ +export const TASK_PROCESSING = { + STATUS: { + CANCELLED: 'STATUS_CANCELLED', + FAILED: 'STATUS_FAILED', + SUCCESSFUL: 'STATUS_SUCCESSFUL', + RUNNING: 'STATUS_RUNNING', + SCHEDULED: 'STATUS_SCHEDULED', + UNKNOWN: 'STATUS_UNKNOWN', + }, +} diff --git a/src/services/coreService.ts b/src/services/coreService.ts index aa88642dedb..d5468efa007 100644 --- a/src/services/coreService.ts +++ b/src/services/coreService.ts @@ -8,6 +8,7 @@ import { generateOcsUrl } from '@nextcloud/router' import { getTalkConfig, hasTalkFeature } from './CapabilitiesManager.ts' import { SHARE } from '../constants.js' +import type { TaskProcessingResponse } from '../types/index.ts' const canInviteToFederation = hasTalkFeature('local', 'federation-v1') && getTalkConfig('local', 'federation', 'enabled') @@ -52,6 +53,11 @@ const autocompleteQuery = async function({ searchText, token = 'new', onlyUsers }) } +const getTaskById = async function(id: number, options?: object): TaskProcessingResponse { + return axios.get(generateOcsUrl('taskprocessing/task/{id}', { id }), options) +} + export { autocompleteQuery, + getTaskById, } diff --git a/src/types/index.ts b/src/types/index.ts index 8e2a819c8fa..aa9d3209d35 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,12 +3,21 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { components, operations } from './openapi/openapi-full.ts' +import { TASK_PROCESSING } from '../constants.js' // General type ApiResponse = Promise<{ data: T }> type ApiResponseHeaders = { [K in keyof T['headers'] as Lowercase]: T['headers'][K]; } +type ApiResponseUnwrapped = Promise<{ + data: { + ocs: { + meta: components['schemas']['OCSMeta'] + data: T + } + } +}> // Capabilities export type Capabilities = { @@ -98,6 +107,7 @@ export type setReadMarkerResponse = ApiResponse export type summarizeChatParams = operations['chat-summarize-chat']['requestBody']['content']['application/json'] export type summarizeChatResponse = ApiResponse +export type SummarizeChatTask = operations['chat-summarize-chat']['responses'][201]['content']['application/json']['ocs']['data'] // Avatars export type setFileAvatarResponse = ApiResponse @@ -181,3 +191,23 @@ export type deletePollDraftResponse = ApiResponse + +// AI Summary +export type TaskProcessingResponse = ApiResponseUnwrapped<{ + task: { + id: number, + lastUpdated: number, + type: string, + status: typeof TASK_PROCESSING.STATUS[keyof typeof TASK_PROCESSING.STATUS], + userId: string, + appId: string, + input: Record, + output: Record | null, + customId: string, + completionExpectedAt: number, + progress: number, + scheduledAt: number, + startedAt: number, + endedAt: number + } +}> From a74842843076745d0647bbff8748406e89529b78 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 21 Nov 2024 18:34:45 +0100 Subject: [PATCH 4/5] feat: request tasks and show summary Signed-off-by: Maksim Sukharev --- src/components/NewMessage/NewMessage.vue | 8 + .../NewMessage/NewMessageAbsenceInfo.vue | 2 + .../NewMessage/NewMessageChatSummary.vue | 247 ++++++++++++++++++ src/stores/chatExtras.js | 14 +- 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/components/NewMessage/NewMessageChatSummary.vue diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue index 1f8ee687fd1..caea931afbe 100644 --- a/src/components/NewMessage/NewMessage.vue +++ b/src/components/NewMessage/NewMessage.vue @@ -38,6 +38,8 @@ :user-absence="userAbsence" :display-name="conversation.displayName" /> + +
+ + + + + + + diff --git a/src/stores/chatExtras.js b/src/stores/chatExtras.js index e022fd6c61c..b0600cb7ca3 100644 --- a/src/stores/chatExtras.js +++ b/src/stores/chatExtras.js @@ -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' @@ -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: { @@ -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) + } + }, }, }) From 59d1386b3a917673ea82dedaa7647b899021810e Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 22 Nov 2024 10:30:38 +0100 Subject: [PATCH 5/5] feat: cancel tasks and dismiss summary Signed-off-by: Maksim Sukharev --- .../NewMessage/NewMessageChatSummary.vue | 57 +++++++++++++++++-- src/services/coreService.ts | 5 ++ src/stores/chatExtras.js | 6 ++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/components/NewMessage/NewMessageChatSummary.vue b/src/components/NewMessage/NewMessageChatSummary.vue index e3df2687a84..a08fee97301 100644 --- a/src/components/NewMessage/NewMessageChatSummary.vue +++ b/src/components/NewMessage/NewMessageChatSummary.vue @@ -32,7 +32,24 @@ class="chat-summary__message" :class="{'chat-summary__message--collapsed': collapsed}">{{ chatSummaryMessage }}

- +
+ + + {{ t('spreed', 'Cancel') }} + + + {{ t('spreed', 'Dismiss') }} + +
@@ -51,7 +68,7 @@ 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 { deleteTaskById, 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' @@ -74,6 +91,7 @@ const collapsed = ref(true) const isTextMoreThanOneLine = ref(false) const loading = ref(true) +const cancelling = ref(false) const store = useStore() const chatExtrasStore = useChatExtrasStore() @@ -159,11 +177,8 @@ async function getTask(token: string, request: TaskProcessingCancelableRequest[' 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) + cancelSummary() break } case TASK_PROCESSING.STATUS.SCHEDULED: @@ -181,6 +196,29 @@ async function getTask(token: string, request: TaskProcessingCancelableRequest[' } } +/** + * + */ +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() +} + /** * */ @@ -229,6 +267,13 @@ function setIsTextMoreThanOneLine() { } } + &__actions { + display: flex; + justify-content: flex-end; + gap: var(--default-grid-baseline); + z-index: 1; + } + &__button { position: absolute !important; top: var(--default-grid-baseline); diff --git a/src/services/coreService.ts b/src/services/coreService.ts index d5468efa007..a8f7ceb3781 100644 --- a/src/services/coreService.ts +++ b/src/services/coreService.ts @@ -57,7 +57,12 @@ const getTaskById = async function(id: number, options?: object): TaskProcessing return axios.get(generateOcsUrl('taskprocessing/task/{id}', { id }), options) } +const deleteTaskById = async function(id: number, options?: object): Promise { + return axios.delete(generateOcsUrl('taskprocessing/task/{id}', { id }), options) +} + export { autocompleteQuery, getTaskById, + deleteTaskById, } diff --git a/src/stores/chatExtras.js b/src/stores/chatExtras.js index b0600cb7ca3..1745bf876cb 100644 --- a/src/stores/chatExtras.js +++ b/src/stores/chatExtras.js @@ -293,5 +293,11 @@ export const useChatExtrasStore = defineStore('chatExtras', { Vue.set(this.chatSummary[token][fromMessageId], 'summary', summary) } }, + + dismissChatSummary(token) { + if (this.hasChatSummaryTaskRequested(token)) { + Vue.delete(this.chatSummary, token) + } + }, }, })