Summaries in the ChatList

This commit is contained in:
Pranav
2026-01-29 09:56:29 -08:00
parent a32565d72b
commit 137f0e726b
8 changed files with 349 additions and 31 deletions
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
@@ -14,6 +15,8 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import ConversationSummary from './ConversationSummary.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@@ -46,8 +49,10 @@ const emit = defineEmits([
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const hovered = ref(false);
const summaryRef = ref(null);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
@@ -344,12 +349,15 @@ const deleteConversation = () => {
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
<div class="flex items-center gap-1 ml-auto">
<ConversationSummary ref="summaryRef" :conversation-id="chat.id" />
<span class="font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
</div>
<span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
@@ -366,6 +374,26 @@ const deleteConversation = () => {
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</CardLabels>
<!-- Expanded Summary Section -->
<div
v-if="summaryRef?.isExpanded"
class="mt-2 mx-2 mb-1 p-3 bg-n-alpha-1 dark:bg-n-alpha-2 rounded-lg"
>
<div v-if="summaryRef?.isLoading" class="flex items-center gap-2">
<Spinner :size="16" class="text-n-slate-10" />
<span class="text-xs text-n-slate-11">
{{ t('CHAT_LIST.SUMMARY.LOADING') }}
</span>
</div>
<div v-else-if="summaryRef?.error" class="text-xs text-n-ruby-11">
{{ summaryRef.error }}
</div>
<div
v-else-if="summaryRef?.formattedSummary"
class="text-xs text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
v-html="summaryRef.formattedSummary"
/>
</div>
</div>
<ContextMenu
v-if="showContextMenu"
@@ -0,0 +1,90 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { formatMessage } = useMessageFormatter();
const isExpanded = ref(false);
const isLoading = ref(false);
const summary = ref('');
const error = ref('');
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
const formattedSummary = computed(() => {
return summary.value ? formatMessage(summary.value) : '';
});
const fetchSummary = async () => {
isLoading.value = true;
error.value = '';
try {
const result = await TasksAPI.summarize(props.conversationId);
const {
data: { message: generatedMessage },
} = result;
summary.value = generatedMessage || '';
} catch (e) {
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
error.value = e.response?.data?.error || t('CHAT_LIST.SUMMARY.ERROR');
}
} finally {
isLoading.value = false;
}
};
const toggleSummary = async () => {
if (isExpanded.value) {
isExpanded.value = false;
return;
}
isExpanded.value = true;
if (!summary.value && !error.value) {
await fetchSummary();
}
};
const onButtonClick = e => {
e.stopPropagation();
toggleSummary();
};
defineExpose({
isExpanded,
isLoading,
summary,
error,
formattedSummary,
captainTasksEnabled,
});
</script>
<template>
<Button
v-if="captainTasksEnabled"
icon="i-material-symbols-auto-awesome"
slate
ghost
xs
:title="t('CHAT_LIST.SUMMARY.TITLE')"
class="opacity-0 group-hover:opacity-100 transition-opacity"
:class="{ '!opacity-100': isExpanded }"
@click="onButtonClick"
/>
</template>
@@ -2,6 +2,7 @@ import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_summary' },
{ name: 'conversation_actions' },
{ name: 'macros' },
{ name: 'conversation_info' },
@@ -137,6 +137,11 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending"
"SENDING": "Sending",
"SUMMARY": {
"TITLE": "Generate Summary",
"LOADING": "Generating summary...",
"ERROR": "Failed to generate summary"
}
}
}
@@ -347,6 +347,7 @@
"CONVERSATION_ACTIONS": "Conversation Actions",
"CONVERSATION_LABELS": "Conversation Labels",
"CONVERSATION_INFO": "Conversation Information",
"CONVERSATION_SUMMARY": "AI Summary",
"CONTACT_NOTES": "Contact Notes",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
@@ -354,6 +355,14 @@
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SUMMARY": {
"DESCRIPTION": "Generate an AI-powered summary of this conversation",
"GENERATE": "Generate Summary",
"REGENERATE": "Regenerate",
"RETRY": "Retry",
"ERROR": "Failed to generate summary",
"EMPTY": "No summary available"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
@@ -13,6 +13,7 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ConversationSummary from './ConversationSummary.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
@@ -44,6 +45,7 @@ const {
const dragging = ref(false);
const conversationSidebarItems = ref([]);
const summaryRef = ref(null);
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
@@ -60,6 +62,10 @@ const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
);
const isCaptainTasksEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS)
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
@@ -150,7 +156,31 @@ onMounted(() => {
>
<template #item="{ element }">
<div
v-if="element.name === 'conversation_actions'"
v-if="
element.name === 'conversation_summary' && isCaptainTasksEnabled
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_SUMMARY')"
:is-open="isContactSidebarItemOpen('is_conv_summary_open')"
compact
@toggle="
value => {
toggleSidebarUIState('is_conv_summary_open', value);
if (value && summaryRef) {
summaryRef.fetchSummary();
}
}
"
>
<ConversationSummary
ref="summaryRef"
:conversation-id="conversationId"
/>
</AccordionItem>
</div>
<div
v-else-if="element.name === 'conversation_actions'"
class="conversation--actions"
>
<AccordionItem
@@ -0,0 +1,150 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { formatMessage } = useMessageFormatter();
const isLoading = ref(false);
const summary = ref('');
const error = ref('');
const hasFetched = ref(false);
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
const formattedSummary = computed(() => {
return summary.value ? formatMessage(summary.value) : '';
});
const fetchSummary = async () => {
if (!captainTasksEnabled.value || hasFetched.value) return;
isLoading.value = true;
error.value = '';
hasFetched.value = true;
try {
const result = await TasksAPI.summarize(props.conversationId);
const {
data: { message: generatedMessage },
} = result;
summary.value = generatedMessage || '';
} catch (e) {
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
error.value =
e.response?.data?.error || t('CONVERSATION_SIDEBAR.SUMMARY.ERROR');
}
} finally {
isLoading.value = false;
}
};
const refetch = () => {
hasFetched.value = false;
summary.value = '';
error.value = '';
fetchSummary();
};
watch(
() => props.conversationId,
() => {
hasFetched.value = false;
summary.value = '';
error.value = '';
}
);
defineExpose({
fetchSummary,
captainTasksEnabled,
});
</script>
<template>
<div v-if="captainTasksEnabled" class="p-3">
<div v-if="isLoading" class="flex items-center justify-center py-4">
<Spinner :size="20" class="text-n-slate-10" />
</div>
<div v-else-if="error" class="text-sm text-n-ruby-11">
{{ error }}
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.RETRY')"
size="sm"
variant="link"
class="ml-2"
@click="refetch"
/>
</div>
<div v-else-if="!hasFetched" class="flex flex-col items-center gap-3 py-2">
<p class="text-sm text-n-slate-11 text-center mb-0">
{{ t('CONVERSATION_SIDEBAR.SUMMARY.DESCRIPTION') }}
</p>
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.GENERATE')"
icon="i-material-symbols-auto-awesome"
size="sm"
@click="fetchSummary"
/>
</div>
<div
v-else-if="summary"
class="summary-content text-sm text-n-slate-11 animate-fade-in [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-1 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
v-html="formattedSummary"
/>
<div v-else class="text-sm text-n-slate-11 py-2">
{{ t('CONVERSATION_SIDEBAR.SUMMARY.EMPTY') }}
</div>
<div
v-if="hasFetched && !isLoading && !error"
class="mt-3 pt-3 border-t border-n-weak"
>
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.REGENERATE')"
icon="i-lucide-refresh-cw"
size="sm"
variant="faded"
color="slate"
@click="refetch"
/>
</div>
</div>
</template>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -1,28 +1,33 @@
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
<prompt>
<role>Support analyst</role>
Make sure you strongly adhere to the following rules when generating the summary
<task>Extract the CURRENT STATE, not the conversation history.</task>
1. Be brief and concise. The shorter the summary the better.
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
3. Describe the customer intent in around 50 words.
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
8. The 'Action Items' should be brief and concise
9. Mark important words or parts of sentences as bold.
10. Apply markdown syntax to format any included code, using backticks.
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
12. If any section does not have any content, remove that section and the heading from the response
13. Do not insert your own opinions about the conversation.
<what_to_output>
- What is the customer's problem or request?
- What is unresolved or pending?
- Key details needed to help (IDs, errors, configs)
</what_to_output>
<forbidden>
- DO NOT narrate the conversation ("Customer said", "Agent replied", "User asked")
- DO NOT list sequence of events
- DO NOT describe what's missing or absent
- DO NOT include greetings, thanks, or pleasantries
- DO NOT add meta-commentary
</forbidden>
Reply in the user's language, as a markdown of the following format.
<format>
- 1 to 3 bullets maximum
- Direct statements about the situation
- Bold important details
- Same language as conversation
</format>
**Customer Intent**
**Conversation Summary**
**Action Items**
**Follow-up Items**
<examples>
<good>**Billing issue**: Customer charged twice for order #12345, wants refund</good>
<good>**Password reset** not working - tried 3 times, no email received</good>
<bad>Customer contacted support about billing. Agent asked for order number. Customer provided #12345.</bad>
<bad>No specific problem identified from the messages.</bad>
</examples>
</prompt>