feat: wire captain overview page to live stats and summary
This commit is contained in:
@@ -21,6 +21,16 @@ class CaptainAssistant extends ApiClient {
|
||||
message_history: messageHistory,
|
||||
});
|
||||
}
|
||||
|
||||
getStats({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, { params: { range } });
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/summary`, {
|
||||
params: { range },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainAssistant();
|
||||
|
||||
+11
-14
@@ -3,20 +3,17 @@ import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
// Knowledge coverage (sample data).
|
||||
const knowledge = {
|
||||
approved: 142,
|
||||
pending: 9,
|
||||
documents: 23,
|
||||
};
|
||||
|
||||
const approvedPct = computed(() => {
|
||||
const total = knowledge.approved + knowledge.pending;
|
||||
return total ? Math.round((knowledge.approved / total) * 100) : 0;
|
||||
});
|
||||
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
|
||||
|
||||
const linkTo = routeName => ({
|
||||
name: routeName,
|
||||
@@ -29,19 +26,19 @@ const linkTo = routeName => ({
|
||||
const stats = computed(() => [
|
||||
{
|
||||
key: 'approved',
|
||||
value: knowledge.approved,
|
||||
value: props.knowledge.approved,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
|
||||
to: linkTo('captain_assistants_responses_index'),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
value: knowledge.pending,
|
||||
value: props.knowledge.pending,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
|
||||
to: linkTo('captain_assistants_responses_pending'),
|
||||
},
|
||||
{
|
||||
key: 'documents',
|
||||
value: knowledge.documents,
|
||||
value: props.knowledge.documents,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
|
||||
to: linkTo('captain_assistants_documents_index'),
|
||||
},
|
||||
|
||||
+70
-32
@@ -1,61 +1,99 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const firstName = computed(() => {
|
||||
const name = currentUser.value?.name?.trim();
|
||||
return name ? name.split(' ')[0] : 'there';
|
||||
const props = defineProps({
|
||||
range: {
|
||||
type: String,
|
||||
default: '30',
|
||||
},
|
||||
});
|
||||
|
||||
// The summary is generated by the model from the assistant's stats using the
|
||||
// captain_overview_summary.liquid prompt (lib/integrations/openai/openai_prompts).
|
||||
// DUMMY: stands in for an LLM-generated summary. In production this markdown
|
||||
// string would come back from the model after we feed it the assistant's
|
||||
// stats; numbers are emphasised with **bold** so we can highlight them.
|
||||
const welcomeMarkdown = computed(
|
||||
() =>
|
||||
`Hey ${firstName.value}, your assistant handled **1,248 conversations** this month and saved your team **612 hours** of work. Auto-resolution climbed **4.1%** while keeping handoffs low. Knowledge coverage is running strong as well.\n\nI'd keep an eye out for credits, you seem to have exhaused nearly **80%** of it.`
|
||||
);
|
||||
const route = useRoute();
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
|
||||
// Split the markdown on **bold** runs so we can render emphasised numbers as
|
||||
// Markdown summary generated by the model from the assistant's stats (served by
|
||||
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
|
||||
// **bold** so we can highlight them.
|
||||
const welcomeMarkdown = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getSummary({
|
||||
assistantId: assistantId.value,
|
||||
range: props.range,
|
||||
});
|
||||
welcomeMarkdown.value = data.message ?? '';
|
||||
} catch {
|
||||
welcomeMarkdown.value = '';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
|
||||
|
||||
// Split a line on **bold** runs so we can render emphasised numbers as
|
||||
// brand-highlighted spans with Tailwind (instead of styling raw HTML).
|
||||
const segments = computed(() => {
|
||||
const parseSegments = text => {
|
||||
const parts = [];
|
||||
const regex = /\*\*(.+?)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match = regex.exec(welcomeMarkdown.value);
|
||||
let match = regex.exec(text);
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ text: welcomeMarkdown.value.slice(lastIndex, match.index) });
|
||||
parts.push({ text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ text: match[1], bold: true });
|
||||
lastIndex = regex.lastIndex;
|
||||
match = regex.exec(welcomeMarkdown.value);
|
||||
match = regex.exec(text);
|
||||
}
|
||||
if (lastIndex < welcomeMarkdown.value.length) {
|
||||
parts.push({ text: welcomeMarkdown.value.slice(lastIndex) });
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ text: text.slice(lastIndex) });
|
||||
}
|
||||
return parts.map((part, index) => ({ ...part, key: index }));
|
||||
});
|
||||
};
|
||||
|
||||
// Break the markdown into paragraphs on blank lines so newline breaks survive
|
||||
// (a single <p> would collapse them), each parsed into highlightable segments.
|
||||
const paragraphs = computed(() =>
|
||||
welcomeMarkdown.value
|
||||
.split(/\n{2,}/)
|
||||
.map(block => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block, index) => ({ key: index, segments: parseSegments(block) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-1.5 text-n-slate-10">
|
||||
<span class="i-lucide-sparkles size-3.5" />
|
||||
<span class="text-xs">
|
||||
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-lg leading-relaxed text-n-slate-12">
|
||||
<template v-for="segment in segments" :key="segment.key">
|
||||
<span v-if="segment.bold" class="font-bold tabular-nums text-n-brand">{{
|
||||
segment.text
|
||||
}}</span>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<p v-if="isLoading" class="text-lg leading-relaxed text-n-slate-10">
|
||||
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LOADING') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<p
|
||||
v-for="paragraph in paragraphs"
|
||||
:key="paragraph.key"
|
||||
class="text-lg leading-relaxed text-n-slate-12"
|
||||
>
|
||||
<template v-for="segment in paragraph.segments" :key="segment.key">
|
||||
<span
|
||||
v-if="segment.bold"
|
||||
class="font-bold tabular-nums text-n-brand"
|
||||
>{{ segment.text }}</span
|
||||
>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -395,7 +395,8 @@
|
||||
"OVERVIEW": {
|
||||
"HEADER": "Overview",
|
||||
"WELCOME": {
|
||||
"LABEL": "Captain summary"
|
||||
"LABEL": "Captain summary",
|
||||
"LOADING": "Generating summary…"
|
||||
},
|
||||
"INBOX_BANNER": {
|
||||
"TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
|
||||
@@ -13,62 +15,83 @@ import QuickLinks from 'dashboard/components-next/captain/pageComponents/overvie
|
||||
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
// NOTE: All figures below are placeholder/sample data. There is no backend
|
||||
// wiring yet; this page exists to explore the layout of the assistant overview.
|
||||
const ranges = ['7', '30', '90'];
|
||||
const selectedRange = ref('30');
|
||||
|
||||
// Headline KPI cards. `trendGood` marks whether the trend direction is a
|
||||
// good outcome for the user, so we can colour the delta independently of sign.
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const stats = ref(null);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getStats({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
});
|
||||
stats.value = data;
|
||||
} catch {
|
||||
stats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
const resolveTrendGood = (trendValue, direction) => {
|
||||
if (direction === 'neutral' || trendValue === 0) return null;
|
||||
return direction === 'up' ? trendValue > 0 : trendValue < 0;
|
||||
};
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, absoluteTrend = false) => {
|
||||
const data = stats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
return {
|
||||
value: formatValue(data.current),
|
||||
trend: absoluteTrend ? `${sign}${data.trend}` : `${sign}${data.trend}%`,
|
||||
trendGood: resolveTrendGood(data.trend, direction),
|
||||
};
|
||||
};
|
||||
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
key: 'handled',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
|
||||
value: '1,248',
|
||||
trend: '+12.4%',
|
||||
trendGood: true,
|
||||
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
|
||||
},
|
||||
{
|
||||
key: 'autoResolution',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
|
||||
value: '63.2%',
|
||||
trend: '+4.1%',
|
||||
trendGood: true,
|
||||
...metricFor('auto_resolution_rate', v => `${v}%`, 'up'),
|
||||
},
|
||||
{
|
||||
key: 'handoff',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
|
||||
value: '28.7%',
|
||||
trend: '-3.2%',
|
||||
trendGood: true,
|
||||
...metricFor('handoff_rate', v => `${v}%`, 'down'),
|
||||
},
|
||||
{
|
||||
key: 'hoursSaved',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
|
||||
value: '612h',
|
||||
trend: '+22.1%',
|
||||
trendGood: true,
|
||||
...metricFor('hours_saved', v => `${v}h`, 'up'),
|
||||
},
|
||||
{
|
||||
key: 'reopen',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
|
||||
value: '6.5%',
|
||||
trend: '+0.8%',
|
||||
trendGood: false,
|
||||
...metricFor('reopen_rate', v => `${v}%`, 'down'),
|
||||
},
|
||||
{
|
||||
key: 'depth',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
|
||||
value: '3.4',
|
||||
trend: '+0.2',
|
||||
trendGood: null,
|
||||
...metricFor('conversation_depth', v => v.toFixed(1), 'neutral', true),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
@@ -103,7 +126,7 @@ const metrics = computed(() => [
|
||||
<div class="flex flex-col gap-6">
|
||||
<InboxBanner />
|
||||
|
||||
<WelcomeCard />
|
||||
<WelcomeCard :range="selectedRange" />
|
||||
|
||||
<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"
|
||||
@@ -120,7 +143,7 @@ const metrics = computed(() => [
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<KnowledgeCard />
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
<ResponseQualityCard />
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user