feat: wire captain overview page to live stats and summary

This commit is contained in:
Shivam Mishra
2026-06-29 22:05:06 +05:30
parent 847bb13aa5
commit 96d0757041
5 changed files with 141 additions and 72 deletions
@@ -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();
@@ -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'),
},
@@ -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>