feat: show Captain generation path on conversation messages [CW-7484] (#15078)
This commit is contained in:
9
app/javascript/dashboard/api/captain/agentSessions.js
Normal file
9
app/javascript/dashboard/api/captain/agentSessions.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainAgentSessions extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/agent_sessions', { accountScoped: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainAgentSessions();
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n, I18nT } from 'vue-i18n';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { useMessageContext } from './provider.js';
|
||||||
|
import { MESSAGE_VARIANTS, ORIENTATION } from './constants';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
messageId: { type: Number, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { orientation, variant, createdAt } = useMessageContext();
|
||||||
|
const store = useStore();
|
||||||
|
const { isCloudFeatureEnabled } = useAccount();
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
const showSparkle = computed(() =>
|
||||||
|
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const session = computed(() =>
|
||||||
|
store.getters['captainAgentSessions/getSessionByMessageId'](props.messageId)
|
||||||
|
);
|
||||||
|
const hasFetched = computed(() =>
|
||||||
|
store.getters['captainAgentSessions/hasFetched'](props.messageId)
|
||||||
|
);
|
||||||
|
const isLoading = computed(
|
||||||
|
() =>
|
||||||
|
!hasFetched.value ||
|
||||||
|
store.getters['captainAgentSessions/isFetching'](props.messageId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const citations = computed(() => session.value?.citations || []);
|
||||||
|
|
||||||
|
const scenarioTitles = computed(() =>
|
||||||
|
(session.value?.scenarios || []).reduce((map, scenario) => {
|
||||||
|
map[scenario.id] = scenario.title;
|
||||||
|
return map;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback for agents without a matching scenario title:
|
||||||
|
// "chatwoot_assistant" → "Chatwoot assistant",
|
||||||
|
// "scenario_5_chatwoot_uptime_agent" → "Chatwoot uptime".
|
||||||
|
const humanizeAgentName = agentName => {
|
||||||
|
const label = agentName
|
||||||
|
.replace(/^scenario_\d+_/, '')
|
||||||
|
.replace(/_agent$/, '')
|
||||||
|
.replaceAll('_', ' ')
|
||||||
|
.trim();
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handoffLabel = agentName => {
|
||||||
|
const scenarioId = agentName.match(/^scenario_(\d+)/)?.[1];
|
||||||
|
return scenarioTitles.value[scenarioId] || humanizeAgentName(agentName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACRONYMS = ['faq', 'api', 'url', 'id', 'sla', 'csat'];
|
||||||
|
|
||||||
|
// Tool names arrive as RubyLLM identifiers like
|
||||||
|
// "captain--tools--faq_lookup" or "custom_get_status_page_overview";
|
||||||
|
// show "FAQ Lookup" / "Get Status Page Overview" instead.
|
||||||
|
const humanizeToolName = name => {
|
||||||
|
return (name || '')
|
||||||
|
.split('--')
|
||||||
|
.pop()
|
||||||
|
.replace(/^custom_/, '')
|
||||||
|
.split('_')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(word =>
|
||||||
|
ACRONYMS.includes(word)
|
||||||
|
? word.toUpperCase()
|
||||||
|
: word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Argument keys are camelCased by the store ("labelName"); show "Label Name".
|
||||||
|
const humanizeArgumentKey = key =>
|
||||||
|
key
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const formatArguments = args => {
|
||||||
|
if (!args || typeof args !== 'object') return '';
|
||||||
|
return Object.entries(args)
|
||||||
|
.map(([key, value]) => `${humanizeArgumentKey(key)}: ${value}`)
|
||||||
|
.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timeline of what Captain did during the run: tool calls (with their
|
||||||
|
// arguments) and scenario/agent handoffs. Message bodies and raw tool
|
||||||
|
// results are intentionally not echoed here.
|
||||||
|
const steps = computed(() => {
|
||||||
|
const runContext = session.value?.runContext;
|
||||||
|
const result = [];
|
||||||
|
let currentAgent = null;
|
||||||
|
|
||||||
|
(Array.isArray(runContext) ? runContext : []).forEach(entry => {
|
||||||
|
if (entry?.role !== 'assistant') return;
|
||||||
|
|
||||||
|
const agentName = entry.agentName;
|
||||||
|
if (agentName && agentName !== currentAgent) {
|
||||||
|
if (currentAgent !== null) {
|
||||||
|
result.push({ type: 'handoff', name: handoffLabel(agentName) });
|
||||||
|
}
|
||||||
|
currentAgent = agentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
(entry.toolCalls || []).forEach(call => {
|
||||||
|
// Agent-to-agent transfers surface as "handoff_to_<agent>" tool calls;
|
||||||
|
// the agent_name change above already yields a handoff step for them.
|
||||||
|
if (call.name?.startsWith('handoff_to_')) return;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
type: 'tool',
|
||||||
|
name: humanizeToolName(call.name),
|
||||||
|
detail: formatArguments(call.arguments),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The final assistant entry stores structured content ({response, reasoning});
|
||||||
|
// surface the model's reasoning for the reply it produced.
|
||||||
|
const reasoning = computed(() => {
|
||||||
|
const runContext = session.value?.runContext;
|
||||||
|
if (!Array.isArray(runContext)) return '';
|
||||||
|
|
||||||
|
const entry = [...runContext]
|
||||||
|
.reverse()
|
||||||
|
.find(item => item?.role === 'assistant' && item.content?.reasoning);
|
||||||
|
return entry?.content?.reasoning || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const STEP_ICONS = {
|
||||||
|
tool: 'i-ph-wrench',
|
||||||
|
handoff: 'i-ph-user-switch',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_KEYPATHS = {
|
||||||
|
tool: 'CONVERSATION.CAPTAIN_GENERATION.STEP_TOOL',
|
||||||
|
handoff: 'CONVERSATION.CAPTAIN_GENERATION.STEP_HANDOFF',
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
|
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
|
||||||
|
|
||||||
|
// Model and credits are only surfaced to super admins and in development.
|
||||||
|
const devDetails = computed(() => {
|
||||||
|
if (!session.value) return null;
|
||||||
|
if (!import.meta.env.DEV && !isSuperAdmin.value) return null;
|
||||||
|
const model = t('CONVERSATION.CAPTAIN_GENERATION.MODEL', {
|
||||||
|
model: session.value.llmModel,
|
||||||
|
});
|
||||||
|
const credits = t('CONVERSATION.CAPTAIN_GENERATION.CREDITS', {
|
||||||
|
credits: session.value.creditsConsumed,
|
||||||
|
});
|
||||||
|
return `${model} · ${credits}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// With the sparkle at the row start, the meta gets pushed to the opposite end;
|
||||||
|
// without it, fall back to the message orientation.
|
||||||
|
const rowLayoutClass = computed(() => {
|
||||||
|
if (showSparkle.value) return 'justify-between';
|
||||||
|
return orientation.value === ORIENTATION.LEFT
|
||||||
|
? 'justify-start'
|
||||||
|
: 'justify-end';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blend the sparkle with the bubble background: amber on private notes,
|
||||||
|
// slate everywhere else. Tokens adapt to dark mode on their own.
|
||||||
|
const sparkleColorClass = computed(() => {
|
||||||
|
if (variant.value === MESSAGE_VARIANTS.PRIVATE) {
|
||||||
|
return isOpen.value
|
||||||
|
? 'text-n-amber-12/80'
|
||||||
|
: 'text-n-amber-12/40 hover:text-n-amber-12/70';
|
||||||
|
}
|
||||||
|
return isOpen.value
|
||||||
|
? 'text-n-slate-12'
|
||||||
|
: 'text-n-slate-11/60 hover:text-n-slate-12';
|
||||||
|
});
|
||||||
|
|
||||||
|
const popoverAlign = computed(() =>
|
||||||
|
orientation.value === ORIENTATION.LEFT ? 'start' : 'end'
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefetch = () => {
|
||||||
|
store.dispatch('captainAgentSessions/fetch', {
|
||||||
|
messageId: props.messageId,
|
||||||
|
createdAt: createdAt.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPopoverShow = () => {
|
||||||
|
isOpen.value = true;
|
||||||
|
prefetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPopoverHide = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-1.5" :class="rowLayoutClass">
|
||||||
|
<Popover
|
||||||
|
v-if="showSparkle"
|
||||||
|
:align="popoverAlign"
|
||||||
|
@show="onPopoverShow"
|
||||||
|
@hide="onPopoverHide"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 p-0 bg-transparent border-0 cursor-pointer"
|
||||||
|
:class="sparkleColorClass"
|
||||||
|
@mouseenter="prefetch"
|
||||||
|
@focus="prefetch"
|
||||||
|
>
|
||||||
|
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
|
||||||
|
<span class="text-xs">
|
||||||
|
{{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex flex-col gap-4 p-4 w-80">
|
||||||
|
<span v-if="isLoading" class="text-xs text-n-slate-11">
|
||||||
|
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="!session" class="text-xs text-n-slate-11">
|
||||||
|
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
|
||||||
|
</span>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="steps.length" class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium text-n-slate-11">
|
||||||
|
{{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div
|
||||||
|
v-for="(step, index) in steps"
|
||||||
|
:key="index"
|
||||||
|
class="flex gap-2.5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center rounded-full size-5 bg-n-alpha-2 text-n-slate-11"
|
||||||
|
>
|
||||||
|
<Icon :icon="STEP_ICONS[step.type]" class="size-3" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="index < steps.length - 1"
|
||||||
|
class="flex-1 w-px min-h-2 bg-n-weak"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col min-w-0 gap-0.5"
|
||||||
|
:class="index < steps.length - 1 ? 'pb-3' : ''"
|
||||||
|
>
|
||||||
|
<I18nT
|
||||||
|
:keypath="STEP_KEYPATHS[step.type]"
|
||||||
|
tag="span"
|
||||||
|
class="text-xs leading-5 text-n-slate-11"
|
||||||
|
>
|
||||||
|
<template #name>
|
||||||
|
<span class="font-medium text-n-slate-12">
|
||||||
|
{{ step.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</I18nT>
|
||||||
|
<span
|
||||||
|
v-if="step.detail"
|
||||||
|
class="text-xs text-n-slate-11 break-words"
|
||||||
|
>
|
||||||
|
{{ step.detail }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="citations.length" class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-baseline gap-1.5">
|
||||||
|
<span class="text-xs font-medium text-n-slate-11">
|
||||||
|
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-n-slate-10">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
|
||||||
|
citations.length
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
|
||||||
|
<li
|
||||||
|
v-for="citation in citations"
|
||||||
|
:key="citation.id"
|
||||||
|
class="text-xs text-n-slate-12"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-if="citation.link"
|
||||||
|
:href="citation.link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-xs text-n-blue-11 hover:underline"
|
||||||
|
>
|
||||||
|
{{ citation.title || citation.link }}
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ citation.title }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="reasoning" class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium text-n-slate-11">
|
||||||
|
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
|
||||||
|
</span>
|
||||||
|
<p class="m-0 text-xs leading-normal text-n-slate-12 break-words">
|
||||||
|
{{ reasoning }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="devDetails" class="text-xs text-n-slate-11">
|
||||||
|
{{ devDetails }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
<slot name="meta" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import MessageMeta from '../MessageMeta.vue';
|
import MessageMeta from '../MessageMeta.vue';
|
||||||
|
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
|
||||||
|
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
|
|||||||
|
|
||||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
hideMeta: { type: Boolean, default: false },
|
hideMeta: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
const {
|
||||||
useMessageContext();
|
variant,
|
||||||
|
orientation,
|
||||||
|
inReplyTo,
|
||||||
|
shouldGroupWithNext,
|
||||||
|
id,
|
||||||
|
sender,
|
||||||
|
senderType,
|
||||||
|
} = useMessageContext();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isCaptainMessage = computed(
|
||||||
|
() =>
|
||||||
|
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
|
||||||
|
);
|
||||||
|
|
||||||
|
const metaColorClass = computed(() =>
|
||||||
|
variant.value === MESSAGE_VARIANTS.PRIVATE
|
||||||
|
? 'text-n-amber-12/50'
|
||||||
|
: 'text-n-slate-11'
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailMetaClass = computed(() =>
|
||||||
|
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
|
||||||
|
);
|
||||||
|
|
||||||
const varaintBaseMap = {
|
const varaintBaseMap = {
|
||||||
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
|
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
|
||||||
[MESSAGE_VARIANTS.PRIVATE]:
|
[MESSAGE_VARIANTS.PRIVATE]:
|
||||||
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
<MessageMeta
|
<template v-if="shouldShowMeta">
|
||||||
v-if="shouldShowMeta"
|
<CaptainGenerationDetails
|
||||||
:class="[
|
v-if="isCaptainMessage"
|
||||||
flexOrientationClass,
|
:message-id="id"
|
||||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
class="mt-2"
|
||||||
variant === MESSAGE_VARIANTS.PRIVATE
|
>
|
||||||
? 'text-n-amber-12/50'
|
<template #meta>
|
||||||
: 'text-n-slate-11',
|
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
|
||||||
]"
|
</template>
|
||||||
class="mt-2"
|
</CaptainGenerationDetails>
|
||||||
/>
|
<MessageMeta
|
||||||
|
v-else
|
||||||
|
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
|
||||||
|
class="mt-2"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -72,6 +72,20 @@
|
|||||||
"RATING_TITLE": "Rating",
|
"RATING_TITLE": "Rating",
|
||||||
"FEEDBACK_TITLE": "Feedback",
|
"FEEDBACK_TITLE": "Feedback",
|
||||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||||
|
"CAPTAIN_GENERATION": {
|
||||||
|
"TITLE": "How was this reply generated?",
|
||||||
|
"GENERATED_BY": "Generated by Captain",
|
||||||
|
"LOADING": "Loading details…",
|
||||||
|
"EMPTY": "No generation details available for this message.",
|
||||||
|
"TIMELINE": "Generation steps",
|
||||||
|
"STEP_TOOL": "Called {name}",
|
||||||
|
"STEP_HANDOFF": "Handed off to {name}",
|
||||||
|
"REASONING": "Reasoning",
|
||||||
|
"SOURCES": "Knowledge base",
|
||||||
|
"SOURCES_SUMMARY": "{count} result | {count} results",
|
||||||
|
"MODEL": "Generated with {model}",
|
||||||
|
"CREDITS": "Credits: {credits}"
|
||||||
|
},
|
||||||
"CARD": {
|
"CARD": {
|
||||||
"SHOW_LABELS": "Show labels",
|
"SHOW_LABELS": "Show labels",
|
||||||
"HIDE_LABELS": "Hide labels",
|
"HIDE_LABELS": "Hide labels",
|
||||||
|
|||||||
62
app/javascript/dashboard/store/captain/agentSessions.js
Normal file
62
app/javascript/dashboard/store/captain/agentSessions.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions';
|
||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
|
const SET_SESSION = 'SET_SESSION';
|
||||||
|
const SET_FETCHING = 'SET_FETCHING';
|
||||||
|
|
||||||
|
// Session capture runs right after the message is broadcast (and well after,
|
||||||
|
// for handoff notes created mid-run), so a 404 on a fresh message may just
|
||||||
|
// mean the session isn't written yet. Skip caching those so a later
|
||||||
|
// hover/click retries; older misses are permanent (V1 messages, failed runs).
|
||||||
|
const RECENT_MESSAGE_WINDOW_SECONDS = 60;
|
||||||
|
|
||||||
|
// Caches Captain agent-session metadata per message id. A missing session
|
||||||
|
// (404) is cached as null so the UI shows an empty state without refetching.
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
sessions: {},
|
||||||
|
fetchingIds: [],
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
getSessionByMessageId: state => messageId => state.sessions[messageId],
|
||||||
|
isFetching: state => messageId => state.fetchingIds.includes(messageId),
|
||||||
|
hasFetched: state => messageId => messageId in state.sessions,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
fetch: async ({ state, commit }, { messageId, createdAt }) => {
|
||||||
|
if (messageId in state.sessions) return;
|
||||||
|
if (state.fetchingIds.includes(messageId)) return;
|
||||||
|
|
||||||
|
commit(SET_FETCHING, { messageId, isFetching: true });
|
||||||
|
try {
|
||||||
|
const { data } = await CaptainAgentSessionsAPI.show(messageId);
|
||||||
|
commit(SET_SESSION, {
|
||||||
|
messageId,
|
||||||
|
session: camelcaseKeys(data, { deep: true }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const isRecentMessage =
|
||||||
|
createdAt &&
|
||||||
|
Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS;
|
||||||
|
// Only a 404 means "no session exists"; transient failures (5xx,
|
||||||
|
// network errors) stay uncached so a later hover retries.
|
||||||
|
if (error.response?.status === 404 && !isRecentMessage) {
|
||||||
|
commit(SET_SESSION, { messageId, session: null });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
commit(SET_FETCHING, { messageId, isFetching: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
[SET_SESSION](state, { messageId, session }) {
|
||||||
|
state.sessions = { ...state.sessions, [messageId]: session };
|
||||||
|
},
|
||||||
|
[SET_FETCHING](state, { messageId, isFetching }) {
|
||||||
|
state.fetchingIds = isFetching
|
||||||
|
? [...state.fetchingIds, messageId]
|
||||||
|
: state.fetchingIds.filter(id => id !== messageId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -50,6 +50,7 @@ import teamMembers from './modules/teamMembers';
|
|||||||
import teams from './modules/teams';
|
import teams from './modules/teams';
|
||||||
import userNotificationSettings from './modules/userNotificationSettings';
|
import userNotificationSettings from './modules/userNotificationSettings';
|
||||||
import webhooks from './modules/webhooks';
|
import webhooks from './modules/webhooks';
|
||||||
|
import captainAgentSessions from './captain/agentSessions';
|
||||||
import captainAssistants from './captain/assistant';
|
import captainAssistants from './captain/assistant';
|
||||||
import captainDocuments from './captain/document';
|
import captainDocuments from './captain/document';
|
||||||
import captainResponses from './captain/response';
|
import captainResponses from './captain/response';
|
||||||
@@ -115,6 +116,7 @@ export default createStore({
|
|||||||
teams,
|
teams,
|
||||||
userNotificationSettings,
|
userNotificationSettings,
|
||||||
webhooks,
|
webhooks,
|
||||||
|
captainAgentSessions,
|
||||||
captainAssistants,
|
captainAssistants,
|
||||||
captainDocuments,
|
captainDocuments,
|
||||||
captainResponses,
|
captainResponses,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ Rails.application.routes.draw do
|
|||||||
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
|
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
|
||||||
resources :scenarios
|
resources :scenarios
|
||||||
end
|
end
|
||||||
|
resources :agent_sessions, only: [:show]
|
||||||
resources :assistant_responses
|
resources :assistant_responses
|
||||||
resources :message_reports, only: [:create]
|
resources :message_reports, only: [:create]
|
||||||
resources :bulk_actions, only: [:create]
|
resources :bulk_actions, only: [:create]
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
class Api::V1::Accounts::Captain::AgentSessionsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :set_message
|
||||||
|
before_action :authorize_conversation
|
||||||
|
|
||||||
|
def show
|
||||||
|
@agent_session = Current.account.captain_agent_sessions.find_by(result_type: 'Message', result_id: @message.id)
|
||||||
|
return head :not_found if @agent_session.blank?
|
||||||
|
|
||||||
|
@citations = Current.account.captain_assistant_responses
|
||||||
|
.where(id: @agent_session.faq_ids)
|
||||||
|
.includes(:documentable)
|
||||||
|
@scenario_titles = Captain::Scenario.where(account_id: Current.account.id, id: @agent_session.scenario_ids)
|
||||||
|
.pluck(:id, :title).to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_message
|
||||||
|
@message = Current.account.messages.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_conversation
|
||||||
|
authorize @message.conversation, :show?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -22,13 +22,12 @@ class Captain::Assistant::SessionCaptureService
|
|||||||
|
|
||||||
def capture!
|
def capture!
|
||||||
model = @assistant.agent_model
|
model = @assistant.agent_model
|
||||||
metadata = context.dig(:state, :cw_metadata) || {}
|
|
||||||
|
|
||||||
Captain::AgentSession.create!(
|
Captain::AgentSession.create!(
|
||||||
assistant: @assistant,
|
assistant: @assistant,
|
||||||
session_type: :assistant,
|
session_type: :assistant,
|
||||||
subject: @conversation,
|
subject: @conversation,
|
||||||
result: @result_message,
|
result: result_message,
|
||||||
llm_model: "#{Llm::Models.provider_for(model)}-#{model}",
|
llm_model: "#{Llm::Models.provider_for(model)}-#{model}",
|
||||||
credits_consumed: @credits_consumed,
|
credits_consumed: @credits_consumed,
|
||||||
faq_ids: metadata[:faq_ids] || [],
|
faq_ids: metadata[:faq_ids] || [],
|
||||||
@@ -44,6 +43,23 @@ class Captain::Assistant::SessionCaptureService
|
|||||||
@run_result.context || {}
|
@run_result.context || {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def metadata
|
||||||
|
@metadata ||= context.dig(:state, :cw_metadata) || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
# On handoff, HandoffTool records the private reason note it created; the session
|
||||||
|
# attaches there so agents can inspect the generation path on the note itself.
|
||||||
|
def result_message
|
||||||
|
handoff_note || @result_message
|
||||||
|
end
|
||||||
|
|
||||||
|
def handoff_note
|
||||||
|
note_id = metadata[:handoff_note_id]
|
||||||
|
return if note_id.blank?
|
||||||
|
|
||||||
|
@conversation.messages.find_by(id: note_id)
|
||||||
|
end
|
||||||
|
|
||||||
def scenario_ids
|
def scenario_ids
|
||||||
ids = current_turn_history.filter_map do |message|
|
ids = current_turn_history.filter_map do |message|
|
||||||
next unless message[:role].to_s == 'assistant'
|
next unless message[:role].to_s == 'assistant'
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
json.id @agent_session.id
|
||||||
|
json.message_id @agent_session.result_id
|
||||||
|
json.llm_model @agent_session.llm_model
|
||||||
|
json.credits_consumed @agent_session.credits_consumed
|
||||||
|
json.run_context @agent_session.run_context.is_a?(Array) ? @agent_session.run_context : []
|
||||||
|
json.citations @citations do |citation|
|
||||||
|
json.id citation.id
|
||||||
|
json.title citation.question
|
||||||
|
# display_url resolves uploaded PDFs to their blob URL; external_link holds a
|
||||||
|
# "PDF: ..." placeholder for those. Guard on scheme so placeholders render as
|
||||||
|
# plain text instead of dead anchors.
|
||||||
|
link = citation.documentable.is_a?(Captain::Document) ? citation.documentable.display_url : nil
|
||||||
|
json.link link&.match?(%r{\Ahttps?://}) ? link : nil
|
||||||
|
end
|
||||||
|
json.scenarios @scenario_titles do |id, title|
|
||||||
|
json.id id
|
||||||
|
json.title title
|
||||||
|
end
|
||||||
@@ -13,7 +13,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Use existing handoff mechanism from ResponseBuilderJob
|
# Use existing handoff mechanism from ResponseBuilderJob
|
||||||
trigger_handoff(conversation, reason)
|
trigger_handoff(tool_context, conversation, reason)
|
||||||
|
|
||||||
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
|
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
@@ -23,9 +23,9 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def trigger_handoff(conversation, reason)
|
def trigger_handoff(tool_context, conversation, reason)
|
||||||
# post the reason as a private note
|
# post the reason as a private note
|
||||||
conversation.messages.create!(
|
note = conversation.messages.create!(
|
||||||
message_type: :outgoing,
|
message_type: :outgoing,
|
||||||
private: true,
|
private: true,
|
||||||
sender: @assistant,
|
sender: @assistant,
|
||||||
@@ -34,6 +34,15 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
|||||||
content: reason
|
content: reason
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Session capture attributes the run to this note so agents can inspect the
|
||||||
|
# generation path on the handoff reason instead of the canned follow-up message.
|
||||||
|
# A reason-less note has no content and never renders in the dashboard, so
|
||||||
|
# leave it unrecorded and let capture fall back to the follow-up message.
|
||||||
|
if reason.present?
|
||||||
|
metadata = tool_context.state[:cw_metadata] ||= {}
|
||||||
|
metadata[:handoff_note_id] = note.id
|
||||||
|
end
|
||||||
|
|
||||||
# Trigger the bot handoff (sets status to open + dispatches events)
|
# Trigger the bot handoff (sets status to open + dispatches events)
|
||||||
conversation.bot_handoff!
|
conversation.bot_handoff!
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::Accounts::Captain::AgentSessions', type: :request do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
let(:inbox) { create(:inbox, account: account) }
|
||||||
|
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:message) do
|
||||||
|
create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant)
|
||||||
|
end
|
||||||
|
|
||||||
|
before { create(:inbox_member, user: agent, inbox: inbox) }
|
||||||
|
|
||||||
|
def json_response
|
||||||
|
JSON.parse(response.body, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/v1/accounts/:account_id/captain/agent_sessions/:id' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the message has an agent session' do
|
||||||
|
let(:document) { create(:captain_document, account: account, assistant: assistant) }
|
||||||
|
let(:documented_faq) do
|
||||||
|
create(:captain_assistant_response, account: account, assistant: assistant,
|
||||||
|
question: 'How do I reset my password?', documentable: document)
|
||||||
|
end
|
||||||
|
let(:plain_faq) do
|
||||||
|
create(:captain_assistant_response, account: account, assistant: assistant, question: 'How do I change my email?')
|
||||||
|
end
|
||||||
|
let(:pdf_document) do
|
||||||
|
create(:captain_document, account: account, assistant: assistant, external_link: nil,
|
||||||
|
pdf_file: Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/sample.pdf'), 'application/pdf'))
|
||||||
|
end
|
||||||
|
let(:pdf_faq) do
|
||||||
|
create(:captain_assistant_response, account: account, assistant: assistant,
|
||||||
|
question: 'What are the pricing tiers?', documentable: pdf_document)
|
||||||
|
end
|
||||||
|
let(:scenario) { create(:captain_scenario, account: account, assistant: assistant, title: 'Refund flow') }
|
||||||
|
let(:run_context) do
|
||||||
|
[
|
||||||
|
{ 'role' => 'user', 'content' => 'I want a refund' },
|
||||||
|
{ 'role' => 'assistant', 'content' => '', 'agent_name' => 'Assistant',
|
||||||
|
'tool_calls' => [{ 'id' => 'call_1', 'name' => 'faq_lookup', 'arguments' => { 'query' => 'refund' } }] },
|
||||||
|
{ 'role' => 'tool', 'content' => 'Refunds take 5 days', 'tool_call_id' => 'call_1' },
|
||||||
|
{ 'role' => 'assistant', 'content' => 'Refunds take 5 days', 'agent_name' => "scenario_#{scenario.id}_refund_flow" }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let!(:agent_session) do
|
||||||
|
create(:captain_agent_session, account: account, assistant: assistant,
|
||||||
|
subject: conversation, result: message,
|
||||||
|
llm_model: 'openai-gpt-5.2', credits_consumed: 1.0,
|
||||||
|
faq_ids: [documented_faq.id, plain_faq.id, pdf_faq.id, documented_faq.id + 100_000],
|
||||||
|
scenario_ids: [scenario.id],
|
||||||
|
run_context: run_context)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the session with hydrated citations and scenarios' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
|
||||||
|
headers: agent.create_new_auth_token, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
aggregate_failures do
|
||||||
|
expect(json_response[:id]).to eq(agent_session.id)
|
||||||
|
expect(json_response[:message_id]).to eq(message.id)
|
||||||
|
expect(json_response[:llm_model]).to eq('openai-gpt-5.2')
|
||||||
|
expect(json_response[:credits_consumed]).to eq(1.0)
|
||||||
|
expect(json_response[:run_context].length).to eq(4)
|
||||||
|
expect(json_response[:run_context].second[:tool_calls].first[:arguments][:query]).to eq('refund')
|
||||||
|
|
||||||
|
citations = json_response[:citations].index_by { |citation| citation[:id] }
|
||||||
|
expect(citations.keys).to contain_exactly(documented_faq.id, plain_faq.id, pdf_faq.id)
|
||||||
|
expect(citations[documented_faq.id][:title]).to eq('How do I reset my password?')
|
||||||
|
expect(citations[documented_faq.id][:link]).to eq(document.external_link)
|
||||||
|
expect(citations[plain_faq.id][:link]).to be_nil
|
||||||
|
expect(pdf_document.external_link).to start_with('PDF:')
|
||||||
|
expect(citations[pdf_faq.id][:link]).to eq(pdf_document.display_url)
|
||||||
|
expect(citations[pdf_faq.id][:link]).to match(%r{\Ahttps?://})
|
||||||
|
|
||||||
|
expect(json_response[:scenarios]).to eq([{ id: scenario.id, title: 'Refund flow' }])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow an agent without access to the conversation' do
|
||||||
|
other_agent = create(:user, account: account, role: :agent)
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
|
||||||
|
headers: other_agent.create_new_auth_token, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the message has no agent session' do
|
||||||
|
it 'returns not found' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
|
||||||
|
headers: agent.create_new_auth_token, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the message does not belong to the account' do
|
||||||
|
it 'returns not found' do
|
||||||
|
other_message = create(:message)
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{other_message.id}",
|
||||||
|
headers: agent.create_new_auth_token, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -561,6 +561,22 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'attributes the handoff session to the private reason note when the tool recorded one' do
|
||||||
|
handoff_note = create(:message, conversation: conversation, account: account, message_type: :outgoing,
|
||||||
|
private: true, sender: assistant, content: 'Needs a human')
|
||||||
|
run_context[:state][:cw_metadata][:handoff_note_id] = handoff_note.id
|
||||||
|
allow(mock_agent_runner_service).to receive(:generate_response) do
|
||||||
|
conversation.update!(status: :open)
|
||||||
|
{ 'response' => 'Let me connect you', 'handoff_tool_called' => true }
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
|
||||||
|
session = Captain::AgentSession.last
|
||||||
|
expect(session.credits_consumed).to eq(0.0)
|
||||||
|
expect(session.result_id).to eq(handoff_note.id)
|
||||||
|
end
|
||||||
|
|
||||||
it 'creates a zero-credit session when the handoff tool fired but failed to commit' do
|
it 'creates a zero-credit session when the handoff tool fired but failed to commit' do
|
||||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
|
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
|
||||||
'response' => 'I tried to hand off',
|
'response' => 'I tried to hand off',
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
|||||||
|
|
||||||
tool.perform(tool_context, reason: reason)
|
tool.perform(tool_context, reason: reason)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'records the handoff note id in the run state for session capture' do
|
||||||
|
tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||||
|
|
||||||
|
expect(tool_context.state[:cw_metadata][:handoff_note_id]).to eq(Message.last.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'without reason provided' do
|
context 'without reason provided' do
|
||||||
@@ -107,6 +113,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
|||||||
|
|
||||||
tool.perform(tool_context)
|
tool.perform(tool_context)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'does not record a handoff note id since the empty note never renders' do
|
||||||
|
tool.perform(tool_context)
|
||||||
|
|
||||||
|
expect(tool_context.state[:cw_metadata]).to be_nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when handoff fails' do
|
context 'when handoff fails' do
|
||||||
|
|||||||
Reference in New Issue
Block a user