Compare commits

..
45 changed files with 766 additions and 341 deletions
+1 -1
View File
@@ -1 +1 @@
4.16.1
4.16.0
@@ -26,20 +26,13 @@ class CaptainAssistant extends ApiClient {
});
}
getMetrics({ assistantId, range, signal }) {
getStats({ assistantId, range, signal }) {
const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
}
getFaqStats({ assistantId, signal }) {
const requestConfig = {};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
}
getSummary({ assistantId, range, stats }) {
@@ -1,6 +1,10 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { useI18n } from 'vue-i18n';
import {
evaluateSLAStatus,
shouldRefreshSLAStatus,
} from 'dashboard/helper/slaHelper';
const props = defineProps({
conversation: {
@@ -12,6 +16,7 @@ const props = defineProps({
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const { t } = useI18n();
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
@@ -24,12 +29,15 @@ const slaEvents = computed(() => props.conversation?.slaEvents);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
return slaStatus.value?.threshold && appliedSLA.value?.id;
return slaStatus.value?.type && appliedSLA.value?.id;
});
const slaStatusText = computed(() => {
return slaStatus.value?.type?.toUpperCase();
});
const slaValueText = computed(
() => slaStatus.value?.threshold || t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
@@ -39,7 +47,24 @@ const updateSlaStatus = () => {
});
};
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
};
const createTimer = () => {
clearTimer();
if (
!shouldRefreshSLAStatus({
appliedSla: appliedSLA.value,
chat: props.conversation,
})
) {
return;
}
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
@@ -52,12 +77,16 @@ onMounted(() => {
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
clearTimer();
});
watch(() => props.conversation, updateSlaStatus);
watch(
() => props.conversation,
() => {
updateSlaStatus();
createTimer();
}
);
// This expose is to provide context to the parent component, so that it can decided weather
// a new row has to be added to the conversation card or not
@@ -96,7 +125,7 @@ defineExpose({
class="text-sm truncate"
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
{{ `${slaStatusText}: ${slaValueText}` }}
</span>
</div>
</template>
@@ -1,6 +1,10 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { useI18n } from 'vue-i18n';
import {
evaluateSLAStatus,
shouldRefreshSLAStatus,
} from 'dashboard/helper/slaHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
@@ -15,6 +19,7 @@ const props = defineProps({
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const { t } = useI18n();
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
@@ -28,8 +33,18 @@ defineOptions({
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const hasSlaThreshold = computed(() => slaStatus.value?.type);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaLabel = computed(() => {
if (slaStatus.value?.threshold) return slaStatus.value.threshold;
const status = t('CONVERSATION.HEADER.SLA_STATUS.MISSED');
return {
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
}[slaStatus.value.type];
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
@@ -39,7 +54,24 @@ const updateSlaStatus = () => {
});
};
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
};
const createTimer = () => {
clearTimer();
if (
!shouldRefreshSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
})
) {
return;
}
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
@@ -52,12 +84,16 @@ onMounted(() => {
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
clearTimer();
});
watch(() => props.chat, updateSlaStatus);
watch(
() => props.chat,
() => {
updateSlaStatus();
createTimer();
}
);
defineExpose({
hasSlaThreshold,
@@ -70,11 +106,7 @@ defineExpose({
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<Label :label="slaLabel" :color="isSlaMissed ? 'ruby' : 'amber'" compact>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
@@ -8,8 +8,6 @@ import {
EditorState,
Selection,
imageResizeView,
toggleMark,
wrapInList,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -19,6 +17,8 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
@@ -33,7 +33,9 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -93,6 +95,7 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -170,6 +173,13 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -454,7 +464,15 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -1,7 +1,10 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import {
evaluateSLAStatus,
shouldRefreshSLAStatus,
} from 'dashboard/helper/slaHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -32,7 +35,7 @@ const slaStatus = ref({
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const hasSlaThreshold = computed(() => slaStatus.value?.type);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
@@ -40,12 +43,24 @@ const slaTextStyles = computed(() =>
const slaStatusText = computed(() => {
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
const status = isSlaMissed.value
? t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
: t('CONVERSATION.HEADER.SLA_STATUS.DUE');
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
return {
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
}[upperCaseType];
});
const showFullStatusText = computed(
() => props.showExtendedInfo && props.parentWidth > 650
);
const slaValueText = computed(
() =>
slaStatus.value?.threshold ||
(showFullStatusText.value ? '' : slaStatusText.value)
);
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
@@ -65,7 +80,24 @@ const updateSlaStatus = () => {
});
};
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
};
const createTimer = () => {
clearTimer();
if (
!shouldRefreshSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
})
) {
return;
}
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
@@ -76,6 +108,7 @@ watch(
() => props.chat,
() => {
updateSlaStatus();
createTimer();
}
);
@@ -91,9 +124,7 @@ onMounted(() => {
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
clearTimer();
});
</script>
@@ -118,7 +149,7 @@ onUnmounted(() => {
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo && parentWidth > 650"
v-if="showFullStatusText"
class="text-xs font-medium"
:class="slaTextStyles"
>
@@ -126,10 +157,11 @@ onUnmounted(() => {
</span>
</div>
<span
v-if="slaValueText"
class="text-xs font-medium"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
>
{{ slaStatus.threshold }}
{{ slaValueText }}
</span>
</div>
<SLAPopoverCard
@@ -78,3 +78,5 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
@@ -1,6 +1,4 @@
import {
InputRule,
inputRules,
MessageMarkdownSerializer,
MessageMarkdownTransformer,
messageSchema,
@@ -11,6 +9,7 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
+63 -34
View File
@@ -47,6 +47,22 @@ const toUnixTimestamp = value => {
: Math.floor(parsedTimestamp / 1000);
};
const isSLACompleted = (sla, conversation) => {
const terminalStatuses = ['hit', 'missed'];
return Boolean(
sla.slaCompletedAt ||
terminalStatuses.includes(sla.slaStatus) ||
conversation.status === 'resolved'
);
};
export const shouldRefreshSLAStatus = ({ appliedSla, chat }) => {
if (!appliedSla || !chat) return false;
return !isSLACompleted(useCamelCase(appliedSla), useCamelCase(chat));
};
/**
* Evaluates SLA status using backend-computed due times
* @param {Object} params - Parameters object
@@ -66,6 +82,9 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
const conversation = useCamelCase(chat);
const events = useCamelCase(slaEvents || []);
const currentTime = Math.floor(Date.now() / 1000);
const completionTime = toUnixTimestamp(sla.slaCompletedAt);
const isCompleted = isSLACompleted(sla, conversation);
const evaluationTime = completionTime || (isCompleted ? null : currentTime);
const slaStatuses = [];
const dueAtByType = {
@@ -84,47 +103,51 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
slaStatuses.push({
type,
threshold: missedAt - currentTime,
threshold: evaluationTime ? missedAt - evaluationTime : null,
icon: 'flame',
isSlaMissed: true,
});
});
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
const shouldCheckFirstResponse =
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
if (!isCompleted) {
const firstReplyCreatedAt = toUnixTimestamp(
conversation.firstReplyCreatedAt
);
const shouldCheckFirstResponse =
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
// Check FRT - until first reply is made on time
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
const threshold = sla.slaFrtDueAt - currentTime;
slaStatuses.push({
type: 'FRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check FRT - until first reply is made on time
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
const threshold = sla.slaFrtDueAt - currentTime;
slaStatuses.push({
type: 'FRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check NRT - only if first reply made and waiting for response
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
const threshold = sla.slaNrtDueAt - currentTime;
slaStatuses.push({
type: 'NRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check NRT - only if first reply made and waiting for response
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
const threshold = sla.slaNrtDueAt - currentTime;
slaStatuses.push({
type: 'NRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check RT - only if conversation is unresolved
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
const threshold = sla.slaRtDueAt - currentTime;
slaStatuses.push({
type: 'RT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
// Check RT - only if conversation is unresolved
if (sla.slaRtDueAt) {
const threshold = sla.slaRtDueAt - currentTime;
slaStatuses.push({
type: 'RT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
}
if (slaStatuses.length === 0) {
@@ -137,13 +160,19 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
return a.isSlaMissed ? -1 : 1;
}
if (a.threshold === null || b.threshold === null) {
if (a.threshold === b.threshold) return 0;
return a.threshold === null ? -1 : 1;
}
return Math.abs(a.threshold) - Math.abs(b.threshold);
});
const mostUrgent = slaStatuses[0];
return {
type: mostUrgent.type,
threshold: formatSLATime(mostUrgent.threshold),
threshold:
mostUrgent.threshold === null ? '' : formatSLATime(mostUrgent.threshold),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
};
@@ -1,4 +1,4 @@
import { evaluateSLAStatus } from '../slaHelper';
import { evaluateSLAStatus, shouldRefreshSLAStatus } from '../slaHelper';
describe('#SLA Helpers', () => {
const currentTimestamp = 1700000000; // Fixed timestamp for testing
@@ -378,6 +378,109 @@ describe('#SLA Helpers', () => {
});
});
describe('completed SLA misses', () => {
it('freezes a recorded FRT miss at the SLA completion time', () => {
const appliedSla = {
sla_status: 'missed',
sla_completed_at: currentTimestamp - 3600,
sla_frt_due_at: currentTimestamp - 7200,
};
const chat = { status: 'resolved' };
const slaEvents = [
{ event_type: 'frt', created_at: currentTimestamp - 7000 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result).toMatchObject({
type: 'FRT',
threshold: '1h',
isSlaMissed: true,
});
});
it('freezes a recorded NRT miss at the SLA completion time', () => {
const appliedSla = {
sla_status: 'missed',
sla_completed_at: currentTimestamp - 3600,
};
const chat = { status: 'resolved' };
const slaEvents = [
{ event_type: 'nrt', created_at: currentTimestamp - 5400 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result).toMatchObject({
type: 'NRT',
threshold: '30m',
isSlaMissed: true,
});
});
it('freezes a recorded RT miss at the SLA completion time', () => {
const appliedSla = {
sla_status: 'missed',
sla_completed_at: currentTimestamp - 3600,
sla_rt_due_at: currentTimestamp - 7200,
};
const chat = { status: 'resolved' };
const slaEvents = [
{ event_type: 'rt', created_at: currentTimestamp - 7000 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result).toMatchObject({
type: 'RT',
threshold: '1h',
isSlaMissed: true,
});
});
it('returns a static miss for a legacy completed SLA without a timestamp', () => {
const appliedSla = {
sla_status: 'missed',
sla_rt_due_at: currentTimestamp - 7200,
};
const chat = { status: 'resolved' };
const slaEvents = [
{ event_type: 'rt', created_at: currentTimestamp - 7000 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result).toMatchObject({
type: 'RT',
threshold: '',
isSlaMissed: true,
});
});
});
describe('refresh scheduling', () => {
it('refreshes only active unresolved SLAs', () => {
expect(
shouldRefreshSLAStatus({
appliedSla: { sla_status: 'active' },
chat: { status: 'open' },
})
).toBe(true);
expect(
shouldRefreshSLAStatus({
appliedSla: { sla_status: 'active' },
chat: { status: 'resolved' },
})
).toBe(false);
expect(
shouldRefreshSLAStatus({
appliedSla: { sla_status: 'missed' },
chat: { status: 'open' },
})
).toBe(false);
});
});
describe('time formatting', () => {
it('formats time in days and hours', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You wont be able to send messages from this conversation anymore.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -58,7 +58,9 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore."
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -26,28 +26,25 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const metricStats = ref(null);
const faqStats = ref(null);
const isFetchingMetrics = ref(false);
const stats = ref(null);
const isFetching = ref(false);
// Increments on every fetch so a response (or retry) from a superseded
// range/assistant can't clobber the latest request's state.
let metricsFetchToken = 0;
let faqStatsFetchToken = 0;
let metricsAbortController = null;
let faqStatsAbortController = null;
let fetchToken = 0;
let abortController = null;
const fetchMetrics = async () => {
metricsFetchToken += 1;
const token = metricsFetchToken;
metricsAbortController?.abort();
metricsAbortController = new AbortController();
const { signal } = metricsAbortController;
metricStats.value = null;
isFetchingMetrics.value = true;
const fetchStats = async () => {
fetchToken += 1;
const token = fetchToken;
abortController?.abort();
abortController = new AbortController();
const { signal } = abortController;
stats.value = null;
isFetching.value = true;
const requestMetrics = () =>
CaptainAssistant.getMetrics({
const requestStats = () =>
CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
signal,
@@ -55,54 +52,25 @@ const fetchMetrics = async () => {
let data = null;
try {
({ data } = await requestMetrics());
({ data } = await requestStats());
} catch {
// One silent retry before giving up, unless the request was aborted.
try {
if (token === metricsFetchToken && !signal.aborted)
({ data } = await requestMetrics());
if (token === fetchToken && !signal.aborted)
({ data } = await requestStats());
} catch {
data = null;
}
}
if (token !== metricsFetchToken || signal.aborted) return;
metricStats.value = data;
isFetchingMetrics.value = false;
if (token !== fetchToken || signal.aborted) return;
stats.value = data;
isFetching.value = false;
};
const fetchFaqStats = async () => {
faqStatsFetchToken += 1;
const token = faqStatsFetchToken;
faqStatsAbortController?.abort();
faqStatsAbortController = new AbortController();
const { signal } = faqStatsAbortController;
faqStats.value = null;
onUnmounted(() => abortController?.abort());
try {
const { data } = await CaptainAssistant.getFaqStats({
assistantId: assistantId.value,
signal,
});
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
} catch {
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
}
};
const summaryStats = computed(() => {
if (!metricStats.value || !faqStats.value) return null;
return { ...metricStats.value, knowledge: faqStats.value };
});
onUnmounted(() => {
metricsAbortController?.abort();
faqStatsAbortController?.abort();
});
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
watch(assistantId, fetchFaqStats, { immediate: true });
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign.
@@ -122,7 +90,7 @@ const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = metricStats.value?.[statKey];
const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
@@ -216,9 +184,9 @@ const closeDrilldown = () => {
<div class="flex flex-col gap-6 pb-8">
<InboxBanner />
<CoverageBanner :knowledge="faqStats ?? undefined" />
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
<WelcomeCard :range="selectedRange" :stats="stats" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
@@ -231,15 +199,13 @@ const closeDrilldown = () => {
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:loading="isFetchingMetrics"
:clickable="
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
"
:loading="isFetching"
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
@click="openDrilldown(metric)"
/>
</div>
<KnowledgeCard :knowledge="faqStats ?? undefined" />
<KnowledgeCard :knowledge="stats?.knowledge" />
<QuickLinks />
</div>
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -44,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -80,6 +84,7 @@ export default {
WhatsappManualMigrationBanner,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -343,6 +348,12 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -809,6 +820,29 @@ export default {
:class="bannerMaxWidth"
@start="openWhatsAppManualMigrationDialog"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.16.1'
version: '4.16.0'
development:
<<: *shared
+1 -2
View File
@@ -66,8 +66,7 @@ Rails.application.routes.draw do
resources :assistants do
member do
post :playground
get :metrics
get :faq_stats
get :stats
get :summary
get :drilldown
end
@@ -0,0 +1,5 @@
class AddCompletedAtToAppliedSlas < ActiveRecord::Migration[7.1]
def change
add_column :applied_slas, :completed_at, :datetime
end
end
@@ -1,17 +0,0 @@
class CreateCaptainMessageSources < ActiveRecord::Migration[7.1]
def change
create_table :captain_message_sources do |t|
t.references :account, null: false, index: true
t.references :assistant, null: false, index: true
t.references :conversation, null: false, index: true
t.references :message, null: false, index: true
t.references :document, null: false, index: true
t.bigint :assistant_response_id, null: false
t.timestamps
end
add_index :captain_message_sources, [:message_id, :assistant_response_id],
unique: true, name: 'idx_captain_message_sources_on_message_and_response'
end
end
+2 -18
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_07_21_100000) do
ActiveRecord::Schema[7.1].define(version: 2026_07_15_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -178,6 +178,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_21_100000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "sla_status", default: 0
t.datetime "completed_at"
t.index ["account_id", "sla_policy_id", "conversation_id"], name: "index_applied_slas_on_account_sla_policy_conversation", unique: true
t.index ["account_id"], name: "index_applied_slas_on_account_id"
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
@@ -477,23 +478,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_21_100000) do
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
end
create_table "captain_message_sources", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "assistant_id", null: false
t.bigint "conversation_id", null: false
t.bigint "message_id", null: false
t.bigint "document_id", null: false
t.bigint "assistant_response_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_captain_message_sources_on_account_id"
t.index ["assistant_id"], name: "index_captain_message_sources_on_assistant_id"
t.index ["conversation_id"], name: "index_captain_message_sources_on_conversation_id"
t.index ["document_id"], name: "index_captain_message_sources_on_document_id"
t.index ["message_id", "assistant_response_id"], name: "idx_captain_message_sources_on_message_and_response", unique: true
t.index ["message_id"], name: "index_captain_message_sources_on_message_id"
end
create_table "captain_scenarios", force: :cascade do |t|
t.string "title"
t.text "description"
@@ -37,23 +37,6 @@ class Captain::AssistantStatsBuilder
build_metrics(current, previous)
end
# Approved/pending FAQ counts and the document total in a single round trip.
def faq_stats
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
)
total = approved + pending
{
approved: approved,
pending: pending,
documents: documents,
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
}
end
private
attr_reader :window
@@ -73,7 +56,8 @@ class Captain::AssistantStatsBuilder
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
knowledge: knowledge
}
end
@@ -89,7 +73,7 @@ class Captain::AssistantStatsBuilder
auto_resolution: rate(resolution[:resolved], handled),
handoff: rate(resolution[:handoff], handled),
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
reopen: reopen_rate(range, resolution[:resolved]),
reopen: reopen_rate(range),
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
}
end
@@ -174,9 +158,7 @@ class Captain::AssistantStatsBuilder
# derived from the assistant's handled conversations (not current inbox membership) so a later
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
def reopen_rate(range, resolved_count)
return 0 if resolved_count.zero?
def reopen_rate(range)
resolved_scope = account.reporting_events
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
conversation_id: handled_scope(range).select(:conversation_id))
@@ -196,7 +178,24 @@ class Captain::AssistantStatsBuilder
'ON resolves.conversation_id = reporting_events.conversation_id ' \
'AND reporting_events.event_end_time >= resolves.event_end_time')
.distinct.count('reporting_events.conversation_id')
rate(reopened, resolved_count)
rate(reopened, resolved_scope.distinct.count(:conversation_id))
end
# Approved/pending FAQ counts and the document total in a single round trip.
def knowledge
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
)
total = approved + pending
{
approved: approved,
pending: pending,
documents: documents,
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
}
end
def rate(numerator, denominator)
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
def index
@assistants = account_assistants.ordered
@@ -42,14 +42,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
@tools = assistant.available_agent_tools
end
def metrics
def stats
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
end
def faq_stats
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
end
def summary
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
result = cached_or_generated_summary(window, summary_stats)
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
@documents = filtered_documents
@documents_count = @documents.count
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
@documents = with_document_usage(@documents).page(@current_page).per(RESULTS_PER_PAGE)
@documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
@@ -61,23 +61,14 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
apply_sort(documents, permitted_params[:sort])
end
def with_document_usage(scope)
response_counts = Captain::AssistantResponse.where(documentable_type: 'Captain::Document')
.group(:documentable_id)
.select('documentable_id AS document_id, COUNT(*) AS responses_count')
source_counts = Captain::MessageSource.group(:document_id).select(
'document_id, COUNT(DISTINCT message_id) AS used_in_answers_count, COUNT(DISTINCT conversation_id) AS used_in_conversations_count'
)
scope.joins("LEFT JOIN (#{response_counts.to_sql}) response_counts ON response_counts.document_id = captain_documents.id")
.joins("LEFT JOIN (#{source_counts.to_sql}) source_counts ON source_counts.document_id = captain_documents.id")
.select('captain_documents.*, COALESCE(response_counts.responses_count, 0) AS responses_count, ' \
'COALESCE(source_counts.used_in_answers_count, 0) AS used_in_answers_count, ' \
'COALESCE(source_counts.used_in_conversations_count, 0) AS used_in_conversations_count')
def with_responses_count(scope)
scope.left_joins(:responses)
.select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count')
.group('captain_documents.id')
end
def set_document
@document = with_document_usage(@documents).find(permitted_params[:id])
@document = @documents.find(permitted_params[:id])
end
def set_assistant
@@ -68,7 +68,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
# left is the customer-facing follow-up message.
process_v2_handoff
end
capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0, capture_message_sources: false)
capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0)
elsif v1_handoff_requested?
# V1 only signals via the response string — no state has been touched yet. If
# the conversation isn't pending anymore, a human took over mid-run; bail out
@@ -83,7 +83,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
end
capture_assistant_session(result_message: message, credits_consumed: 1.0, capture_message_sources: captain_v2_enabled?)
capture_assistant_session(result_message: message, credits_consumed: 1.0)
end
end
@@ -142,10 +142,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
# Capture runs outside the delivery transaction and never raises (the service
# swallows its own failures): a session-logging bug must never roll back the
# customer reply or trigger the top-level handle_error handoff on top of it.
def capture_assistant_session(result_message:, credits_consumed:, capture_message_sources:)
def capture_assistant_session(result_message:, credits_consumed:)
Captain::Assistant::SessionCaptureService.new(assistant: @assistant, conversation: @conversation, run_result: @run_result,
result_message: result_message, credits_consumed: credits_consumed,
capture_message_sources: capture_message_sources).capture
result_message: result_message, credits_consumed: credits_consumed).capture
end
def handle_error(error)
+2
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# sla_status :integer default("active")
# completed_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
@@ -53,6 +54,7 @@ class AppliedSla < ApplicationRecord
sla_status: sla_status,
created_at: created_at.to_i,
updated_at: updated_at.to_i,
sla_completed_at: completed_at&.to_i,
sla_description: sla_policy.description,
sla_name: sla_policy.name,
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
@@ -34,7 +34,6 @@ class Captain::Document < ApplicationRecord
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
has_many :message_sources, class_name: 'Captain::MessageSource', dependent: :destroy_async
belongs_to :account
has_one_attached :pdf_file
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
@@ -1,33 +0,0 @@
# == Schema Information
#
# Table name: captain_message_sources
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
# assistant_response_id :bigint not null
# conversation_id :bigint not null
# document_id :bigint not null
# message_id :bigint not null
#
# Indexes
#
# idx_captain_message_sources_on_message_and_response (message_id,assistant_response_id) UNIQUE
# index_captain_message_sources_on_account_id (account_id)
# index_captain_message_sources_on_assistant_id (assistant_id)
# index_captain_message_sources_on_conversation_id (conversation_id)
# index_captain_message_sources_on_document_id (document_id)
# index_captain_message_sources_on_message_id (message_id)
#
class Captain::MessageSource < ApplicationRecord
self.table_name = 'captain_message_sources'
belongs_to :account
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :conversation, class_name: '::Conversation'
belongs_to :message
belongs_to :document, class_name: 'Captain::Document'
belongs_to :assistant_response, class_name: 'Captain::AssistantResponse', optional: true
end
@@ -16,7 +16,6 @@ module Enterprise::Concerns::Account
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
has_many :captain_message_sources, dependent: :destroy_async, class_name: 'Captain::MessageSource'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
@@ -4,6 +4,5 @@ module Enterprise::Concerns::Message
included do
has_one :call, dependent: :nullify
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
has_many :captain_message_sources, class_name: 'Captain::MessageSource', dependent: :destroy_async
end
end
@@ -33,6 +33,18 @@ module Enterprise::Conversation
private
def handle_resolved_status_change
super
update_applied_sla_completion
end
def update_applied_sla_completion
return unless saved_change_to_status?
return if applied_sla.blank? || applied_sla.hit? || applied_sla.missed?
applied_sla.update!(completed_at: resolved? ? Time.current : nil)
end
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
@@ -7,11 +7,7 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
def metrics?
true
end
def faq_stats?
def stats?
true
end
@@ -1,13 +1,12 @@
class Captain::Assistant::SessionCaptureService
SCENARIO_AGENT_REGEX = /\A#{Captain::Scenario::HANDOFF_KEY_PREFIX}_(\d+)_/
def initialize(assistant:, conversation:, run_result:, result_message:, **options)
def initialize(assistant:, conversation:, run_result:, result_message:, credits_consumed:)
@assistant = assistant
@conversation = conversation
@run_result = run_result
@result_message = result_message
@credits_consumed = options.fetch(:credits_consumed)
@capture_message_sources = options.fetch(:capture_message_sources, false)
@credits_consumed = credits_consumed
end
def capture
@@ -24,7 +23,7 @@ class Captain::Assistant::SessionCaptureService
def capture!
model = @assistant.agent_model
session = Captain::AgentSession.create!(
Captain::AgentSession.create!(
assistant: @assistant,
session_type: :assistant,
subject: @conversation,
@@ -36,8 +35,6 @@ class Captain::Assistant::SessionCaptureService
scenario_ids: scenario_ids,
run_context: current_turn_history
)
capture_message_sources(metadata) if @capture_message_sources
session
end
private
@@ -73,42 +70,6 @@ class Captain::Assistant::SessionCaptureService
ids & @assistant.scenarios.where(id: ids).pluck(:id)
end
def capture_message_sources(metadata)
sources = Array(metadata[:message_sources])
return if sources.empty?
document_ids = @assistant.documents.where(id: sources.pluck(:document_id)).pluck(:id)
return if document_ids.empty?
insert_message_sources(message_source_rows(sources, document_ids))
end
def message_source_rows(sources, document_ids)
timestamp = Time.current
sources.filter_map do |source|
next unless document_ids.include?(source[:document_id])
{
account_id: @assistant.account_id,
assistant_id: @assistant.id,
conversation_id: @conversation.id,
message_id: @result_message.id,
document_id: source[:document_id],
assistant_response_id: source[:assistant_response_id],
created_at: timestamp,
updated_at: timestamp
}
end
end
def insert_message_sources(rows)
return if rows.empty?
# rubocop:disable Rails/SkipsModelValidations
Captain::MessageSource.insert_all(rows, unique_by: :idx_captain_message_sources_on_message_and_response)
# rubocop:enable Rails/SkipsModelValidations
end
# Trim to the current turn: the last user message and everything after it
# (assistant replies, tool calls/results, handoff hops).
def current_turn_history
@@ -0,0 +1,106 @@
class Sla::BackfillAppliedSlaCompletedAtService
DEFAULT_BATCH_SIZE = 500
def initialize(**options)
options.assert_valid_keys(:account_id, :all_accounts, :apply, :batch_size, :after_id, :output)
@account_id = options[:account_id]
@all_accounts = options.fetch(:all_accounts, false)
@apply = options.fetch(:apply, false)
@batch_size = options.fetch(:batch_size, DEFAULT_BATCH_SIZE)
@after_id = options.fetch(:after_id, 0)
@output = options.fetch(:output, $stdout)
end
def perform
validate_options!
scope = candidate_scope
eligible_count = scope.count
counters = { processed: 0, matched: 0, updated: 0, skipped: 0, last_id: @after_id }
print_preflight(eligible_count)
scope.find_in_batches(batch_size: @batch_size, start: @after_id + 1) { |batch| process_batch(batch, counters) }
result = counters.merge(eligible: eligible_count, dry_run: !@apply)
@output.puts "Completed: #{result.inspect}"
result
end
private
def process_batch(batch, counters)
resolution_times = resolution_times_for(batch)
updated_count = @apply ? bulk_update(resolution_times) : 0
counters[:processed] += batch.size
counters[:matched] += resolution_times.size
counters[:updated] += updated_count
counters[:skipped] += batch.size - resolution_times.size
counters[:last_id] = batch.last.id
@output.puts "Processed through applied_sla_id=#{counters[:last_id]} " \
"(matched=#{counters[:matched]}, updated=#{counters[:updated]}, skipped=#{counters[:skipped]})"
end
def validate_options!
account_scope = @account_id.present?
raise ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true' if account_scope == @all_accounts
raise ArgumentError, 'BATCH_SIZE must be greater than zero' unless @batch_size.positive?
raise ArgumentError, 'AFTER_ID must be zero or greater' if @after_id.negative?
Account.find(@account_id) if account_scope
end
def candidate_scope
scope = AppliedSla.where(sla_status: :missed, completed_at: nil).where('applied_slas.id > ?', @after_id)
scope = scope.where(account_id: @account_id) if @account_id.present?
scope
end
def resolution_times_for(batch)
events_by_conversation = ReportingEvent
.where(
account_id: batch.map(&:account_id).uniq,
conversation_id: batch.map(&:conversation_id),
name: 'conversation_resolved'
)
.where.not(event_end_time: nil)
.order(:conversation_id, event_end_time: :desc)
.group_by(&:conversation_id)
batch.each_with_object({}) do |applied_sla, resolution_times|
event = events_by_conversation.fetch(applied_sla.conversation_id, []).find do |reporting_event|
reporting_event.event_end_time.between?(applied_sla.created_at, applied_sla.updated_at)
end
resolution_times[applied_sla.id] = event.event_end_time if event
end
end
def bulk_update(resolution_times)
return 0 if resolution_times.empty?
connection = AppliedSla.connection
values = resolution_times.map do |id, completed_at|
"(#{connection.quote(id)}, #{connection.quote(completed_at)}::timestamp)"
end.join(', ')
statement = <<~SQL.squish
UPDATE #{connection.quote_table_name(AppliedSla.table_name)} AS applied_slas
SET completed_at = backfill.completed_at
FROM (VALUES #{values}) AS backfill(id, completed_at)
WHERE applied_slas.id = backfill.id
AND applied_slas.completed_at IS NULL
SQL
connection.exec_update(statement, 'Backfill applied SLA completed_at')
end
def print_preflight(eligible_count)
scope = @account_id.present? ? "account_id=#{@account_id}" : 'all accounts'
mode = @apply ? 'APPLY' : 'DRY RUN'
@output.puts "Applied SLA completed_at backfill: mode=#{mode}, scope=#{scope}, batch_size=#{@batch_size}, after_id=#{@after_id}"
@output.puts "Eligible missed applied SLAs: #{eligible_count}"
end
end
@@ -3,6 +3,7 @@ json.sla_id resource.sla_policy_id
json.sla_status resource.sla_status
json.created_at resource.created_at.to_i
json.updated_at resource.updated_at.to_i
json.sla_completed_at resource.completed_at&.to_i
json.sla_description resource.sla_policy.description
json.sla_name resource.sla_policy.name
json.sla_first_response_time_threshold resource.sla_policy.first_response_time_threshold
@@ -11,10 +11,6 @@ json.file_size resource.file_size
json.pdf_document resource.pdf_document?
responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count
json.responses_count responses_count.to_i
used_in_answers_count = resource.respond_to?(:used_in_answers_count) ? resource.used_in_answers_count : resource.message_sources.distinct.count(:message_id)
json.used_in_answers_count used_in_answers_count.to_i
used_in_conversations_count = resource.respond_to?(:used_in_conversations_count) ? resource.used_in_conversations_count : resource.message_sources.distinct.count(:conversation_id)
json.used_in_conversations_count used_in_conversations_count.to_i
json.id resource.id
json.name resource.name
json.status resource.status
@@ -26,17 +26,8 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
metadata = tool_context.state[:cw_metadata] ||= {}
metadata[:faq_ids] = Array(metadata[:faq_ids]) | responses.map(&:id)
document_ids = document_responses(responses).map(&:documentable_id)
document_ids = responses.filter_map { |response| response.documentable_id if response.documentable_type == 'Captain::Document' }
metadata[:document_ids] = Array(metadata[:document_ids]) | document_ids
metadata[:message_sources] = Array(metadata[:message_sources]) | message_sources(document_responses(responses))
end
def document_responses(responses)
responses.select { |response| response.documentable_type == 'Captain::Document' }
end
def message_sources(responses)
responses.map { |response| { assistant_response_id: response.id, document_id: response.documentable_id } }
end
def format_responses(responses)
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.16.1",
"version": "4.16.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.23",
"@chatwoot/prosemirror-schema": "1.3.22",
"@chatwoot/utils": "^0.0.56",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
@@ -86,6 +86,9 @@
"mitt": "^3.0.1",
"opus-recorder": "^8.0.5",
"pinia": "^3.0.4",
"prosemirror-commands": "^1.7.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-schema-list": "^1.5.1",
"qrcode": "^1.5.4",
"semver": "7.6.3",
"snakecase-keys": "^8.0.1",
+22 -6
View File
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.3.23
version: 1.3.23
specifier: 1.3.22
version: 1.3.22
'@chatwoot/utils':
specifier: ^0.0.56
version: 0.0.56
@@ -180,6 +180,15 @@ importers:
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
prosemirror-commands:
specifier: ^1.7.1
version: 1.7.1
prosemirror-inputrules:
specifier: ^1.4.0
version: 1.4.0
prosemirror-schema-list:
specifier: ^1.5.1
version: 1.5.1
qrcode:
specifier: ^1.5.4
version: 1.5.4
@@ -452,8 +461,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.3.23':
resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==}
'@chatwoot/prosemirror-schema@1.3.22':
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
'@chatwoot/utils@0.0.56':
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
@@ -3992,6 +4001,9 @@ packages:
prosemirror-tables@1.5.0:
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
prosemirror-transform@1.10.0:
resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==}
prosemirror-transform@1.12.0:
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
@@ -5124,7 +5136,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.3.23':
'@chatwoot/prosemirror-schema@1.3.22':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
@@ -9023,7 +9035,7 @@ snapshots:
dependencies:
prosemirror-model: 1.22.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.12.0
prosemirror-transform: 1.10.0
prosemirror-state@1.4.3:
dependencies:
@@ -9039,6 +9051,10 @@ snapshots:
prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
prosemirror-transform@1.10.0:
dependencies:
prosemirror-model: 1.22.3
prosemirror-transform@1.12.0:
dependencies:
prosemirror-model: 1.22.3
@@ -0,0 +1,30 @@
# Backfill applied_slas.completed_at from conversation resolution reporting events.
#
# Account-scoped dry run:
# ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb
#
# Account-scoped apply:
# ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb
#
# Explicit global apply with resume controls:
# ALL_ACCOUNTS=true APPLY=true BATCH_SIZE=500 AFTER_ID=0 \
# bundle exec rails runner script/backfill_applied_sla_completed_at.rb
begin
account_id = Integer(ENV.fetch('ACCOUNT_ID'), 10) if ENV['ACCOUNT_ID'].present?
all_accounts = ENV['ALL_ACCOUNTS'] == 'true'
apply = ENV['APPLY'] == 'true'
batch_size = Integer(ENV.fetch('BATCH_SIZE', Sla::BackfillAppliedSlaCompletedAtService::DEFAULT_BATCH_SIZE.to_s), 10)
after_id = Integer(ENV.fetch('AFTER_ID', '0'), 10)
Sla::BackfillAppliedSlaCompletedAtService.new(
account_id: account_id,
all_accounts: all_accounts,
apply: apply,
batch_size: batch_size,
after_id: after_id
).perform
rescue ArgumentError, ActiveRecord::RecordNotFound => e
warn "Backfill aborted: #{e.message}"
exit 1
end
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end
@@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
end
end
describe '#faq_stats' do
describe '#metrics knowledge' do
before do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
@@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
end
it 'returns approved, pending, document counts and coverage' do
knowledge = described_class.new(assistant).faq_stats
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
end
@@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
it 'reports zero coverage when there are no responses' do
Captain::AssistantResponse.where(assistant: assistant).delete_all
knowledge = described_class.new(assistant).faq_stats
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
expect(knowledge[:coverage]).to eq(0)
end
@@ -8,13 +8,14 @@ RSpec.describe 'Conversations API', type: :request do
it 'returns SLA data for the conversation if the feature is enabled' do
account.enable_features!('sla')
conversation = create(:conversation, account: account)
applied_sla = create(:applied_sla, conversation: conversation)
applied_sla = create(:applied_sla, conversation: conversation, completed_at: 1.hour.ago)
sla_event = create(:sla_event, conversation: conversation, applied_sla: applied_sla)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body['applied_sla']['id']).to eq(applied_sla.id)
expect(response.parsed_body['applied_sla']['sla_completed_at']).to eq(applied_sla.completed_at.to_i)
expect(response.parsed_body['sla_events'].first['id']).to eq(sla_event.id)
end
@@ -17,6 +17,7 @@ RSpec.describe AppliedSla, type: :model do
sla_status: applied_sla.sla_status,
created_at: applied_sla.created_at.to_i,
updated_at: applied_sla.updated_at.to_i,
sla_completed_at: nil,
sla_description: applied_sla.sla_policy.description,
sla_name: applied_sla.sla_policy.name,
sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold,
@@ -41,6 +41,37 @@ RSpec.describe Conversation, type: :model do
# end
end
describe 'SLA completion' do
let(:applied_sla) { create(:applied_sla) }
let(:conversation) { applied_sla.conversation }
it 'records the completion time when the conversation is resolved' do
completion_time = Time.zone.parse('2026-07-15 10:00:00')
travel_to(completion_time) { conversation.update!(status: :resolved) }
expect(applied_sla.reload.completed_at).to eq(completion_time)
end
it 'clears the completion time when a nonterminal SLA is reopened' do
conversation.update!(status: :resolved)
conversation.update!(status: :open)
expect(applied_sla.reload.completed_at).to be_nil
end
it 'preserves the completion time when a terminal SLA is reopened' do
conversation.update!(status: :resolved)
completed_at = applied_sla.reload.completed_at
applied_sla.update!(sla_status: :missed)
conversation.update!(status: :open)
expect(applied_sla.reload.completed_at).to eq(completed_at)
end
end
describe 'sla_policy' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
@@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do
permissions :index?, :show?, :playground? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
@@ -0,0 +1,117 @@
require 'rails_helper'
RSpec.describe Sla::BackfillAppliedSlaCompletedAtService do
let(:output) { StringIO.new }
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:applied_sla) do
create(
:applied_sla,
account: account,
conversation: conversation,
sla_status: :missed,
created_at: 3.days.ago,
updated_at: 1.day.ago
)
end
let!(:resolution_event) do
create(
:reporting_event,
account: account,
inbox: conversation.inbox,
conversation: conversation,
name: 'conversation_resolved',
event_start_time: applied_sla.created_at,
event_end_time: 2.days.ago
)
end
it 'defaults to a dry run' do
result = described_class.new(account_id: account.id, output: output).perform
expect(result).to include(dry_run: true, eligible: 1, matched: 1, updated: 0, skipped: 0)
expect(applied_sla.reload.completed_at).to be_nil
end
it 'backfills the latest reliable resolution without changing updated_at' do
latest_resolution = create(
:reporting_event,
account: account,
inbox: conversation.inbox,
conversation: conversation,
name: 'conversation_resolved',
event_start_time: applied_sla.created_at,
event_end_time: 36.hours.ago
)
original_updated_at = applied_sla.updated_at
result = described_class.new(account_id: account.id, apply: true, output: output).perform
expect(result).to include(dry_run: false, eligible: 1, matched: 1, updated: 1, skipped: 0)
expect(applied_sla.reload.completed_at).to eq(latest_resolution.reload.event_end_time)
expect(applied_sla.updated_at).to eq(original_updated_at)
end
it 'skips records without a reliable resolution event' do
resolution_event.destroy!
result = described_class.new(account_id: account.id, apply: true, output: output).perform
expect(result).to include(eligible: 1, matched: 0, updated: 0, skipped: 1)
expect(applied_sla.reload.completed_at).to be_nil
end
it 'is idempotent' do
service = described_class.new(account_id: account.id, apply: true, output: output)
service.perform
result = service.perform
expect(result).to include(eligible: 0, matched: 0, updated: 0, skipped: 0)
expect(applied_sla.reload.completed_at).to eq(resolution_event.reload.event_end_time)
end
it 'requires exactly one account scope' do
expect { described_class.new(output: output).perform }
.to raise_error(ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true')
expect { described_class.new(account_id: account.id, all_accounts: true, output: output).perform }
.to raise_error(ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true')
end
it 'limits account runs and requires explicit global scope for other accounts' do
other_account = create(:account)
other_conversation = create(:conversation, account: other_account)
other_applied_sla = create(
:applied_sla,
account: other_account,
conversation: other_conversation,
sla_status: :missed,
created_at: 3.days.ago,
updated_at: 1.day.ago
)
other_resolution_event = create(
:reporting_event,
account: other_account,
inbox: other_conversation.inbox,
conversation: other_conversation,
name: 'conversation_resolved',
event_start_time: other_applied_sla.created_at,
event_end_time: 2.days.ago
)
described_class.new(account_id: account.id, apply: true, output: output).perform
expect(applied_sla.reload.completed_at).to eq(resolution_event.reload.event_end_time)
expect(other_applied_sla.reload.completed_at).to be_nil
described_class.new(all_accounts: true, apply: true, output: output).perform
expect(other_applied_sla.reload.completed_at).to eq(other_resolution_event.reload.event_end_time)
end
it 'resumes after the supplied applied SLA id' do
result = described_class.new(account_id: account.id, after_id: applied_sla.id, output: output).perform
expect(result).to include(eligible: 0, processed: 0, last_id: applied_sla.id)
end
end