feat(captain): capture and surface how a reply was generated
Records reasoning, citations (the FAQs the assistant found), the tool path, and the model for each V1 Captain reply in a new captain_message_generations table. The assistant reports the source IDs it actually used so cited FAQs can be flagged, and handoff messages capture the transfer reason. Agents can expand any Captain message in the conversation (a sparkle toggle on the timestamp line) to see this breakdown, fetched on demand from a dedicated endpoint. Model is shown only in development. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
dfd656a7d9
commit
eb203bb144
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class MessageGenerations extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/message_generations', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageGenerations();
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from './provider.js';
|
||||
import { ORIENTATION } from './constants';
|
||||
import MessageGenerationsAPI from 'dashboard/api/captain/messageGenerations';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: Number, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { orientation } = useMessageContext();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const hasFetched = ref(false);
|
||||
const generation = ref(null);
|
||||
|
||||
const reasoning = computed(() => generation.value?.reasoning);
|
||||
const citations = computed(() => generation.value?.citations || []);
|
||||
const generationPath = computed(() => generation.value?.generationPath || []);
|
||||
const tools = computed(() =>
|
||||
generationPath.value.map(step => step?.tool).filter(Boolean)
|
||||
);
|
||||
// Model is only surfaced in development to aid debugging.
|
||||
const model = computed(() =>
|
||||
import.meta.env.DEV ? generation.value?.model : null
|
||||
);
|
||||
|
||||
const searchQuery = computed(() => {
|
||||
const step = generationPath.value.find(
|
||||
s => s?.tool === 'search_documentation'
|
||||
);
|
||||
return step?.arguments?.query || '';
|
||||
});
|
||||
|
||||
const hasUsedCitation = computed(() => citations.value.some(c => c.used));
|
||||
|
||||
const sourcesSummary = computed(() => {
|
||||
const summary = t('CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY', {
|
||||
count: citations.value.length,
|
||||
});
|
||||
if (!searchQuery.value) return summary;
|
||||
|
||||
const searched = t('CONVERSATION.CAPTAIN_GENERATION.SEARCHED_FOR', {
|
||||
query: searchQuery.value,
|
||||
});
|
||||
return `${summary} · ${searched}`;
|
||||
});
|
||||
|
||||
// Surface the FAQ(s) Captain actually used in the reply ahead of the rest.
|
||||
const sortedCitations = computed(() =>
|
||||
[...citations.value].sort((a, b) => Number(b.used) - Number(a.used))
|
||||
);
|
||||
|
||||
const hasDetails = computed(
|
||||
() =>
|
||||
Boolean(reasoning.value) ||
|
||||
citations.value.length > 0 ||
|
||||
tools.value.length > 0
|
||||
);
|
||||
|
||||
const rowAlignClass = computed(() =>
|
||||
orientation.value === ORIENTATION.LEFT ? 'justify-start' : 'justify-end'
|
||||
);
|
||||
|
||||
const fetchGeneration = async () => {
|
||||
if (hasFetched.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { data } = await MessageGenerationsAPI.show(props.messageId);
|
||||
generation.value = useCamelCase(data, { deep: true });
|
||||
} catch (error) {
|
||||
generation.value = null;
|
||||
} finally {
|
||||
hasFetched.value = true;
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
if (isExpanded.value) fetchGeneration();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Transition
|
||||
enter-active-class="transition-[opacity,transform] duration-200 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-[opacity,transform] duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="isExpanded"
|
||||
class="flex flex-col gap-3 p-3 text-xs rounded-lg bg-n-alpha-black1"
|
||||
>
|
||||
<span v-if="isLoading">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
|
||||
</span>
|
||||
<span v-else-if="!hasDetails">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
|
||||
</span>
|
||||
<template v-else>
|
||||
<div v-if="reasoning" class="flex flex-col gap-1">
|
||||
<span class="font-medium opacity-70">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
|
||||
</span>
|
||||
<p class="m-0 whitespace-pre-line">{{ reasoning }}</p>
|
||||
</div>
|
||||
<div v-if="citations.length" class="flex flex-col gap-1.5">
|
||||
<span class="font-medium opacity-70">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
|
||||
</span>
|
||||
<p class="m-0 opacity-70">{{ sourcesSummary }}</p>
|
||||
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
|
||||
<li
|
||||
v-for="(citation, index) in sortedCitations"
|
||||
:key="index"
|
||||
:class="[
|
||||
{ 'opacity-50': hasUsedCitation && !citation.used },
|
||||
citation.used ? 'font-medium' : '',
|
||||
]"
|
||||
>
|
||||
<a
|
||||
v-if="citation.source"
|
||||
:href="citation.source"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-n-blue-11 hover:underline"
|
||||
>
|
||||
{{ citation.title || citation.source }}
|
||||
</a>
|
||||
<span v-else>{{ citation.title }}</span>
|
||||
<span
|
||||
v-if="citation.used"
|
||||
class="px-1 ml-1 rounded bg-n-alpha-2 text-n-teal-10"
|
||||
>
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.USED') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="tools.length" class="flex flex-col gap-1">
|
||||
<span class="font-medium opacity-70">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.TOOLS') }}
|
||||
</span>
|
||||
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
|
||||
<li v-for="(tool, index) in tools" :key="index">{{ tool }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span v-if="model" class="opacity-70">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.MODEL', { model }) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="flex items-center gap-1.5" :class="rowAlignClass">
|
||||
<slot name="meta" />
|
||||
<button
|
||||
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center bg-transparent border-0 cursor-pointer text-n-slate-10 hover:text-n-slate-11"
|
||||
:class="isExpanded ? 'text-n-slate-11' : ''"
|
||||
@click="toggle"
|
||||
>
|
||||
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MessageMeta from '../MessageMeta.vue';
|
||||
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
|
||||
|
||||
const props = defineProps({
|
||||
hideMeta: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
useMessageContext();
|
||||
const {
|
||||
variant,
|
||||
orientation,
|
||||
inReplyTo,
|
||||
shouldGroupWithNext,
|
||||
id,
|
||||
sender,
|
||||
senderType,
|
||||
} = useMessageContext();
|
||||
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 = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.PRIVATE]:
|
||||
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
v-if="shouldShowMeta"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
||||
variant === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11',
|
||||
]"
|
||||
class="mt-2"
|
||||
/>
|
||||
<template v-if="shouldShowMeta">
|
||||
<CaptainGenerationDetails
|
||||
v-if="isCaptainMessage"
|
||||
:message-id="id"
|
||||
class="mt-2"
|
||||
>
|
||||
<template #meta>
|
||||
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
|
||||
</template>
|
||||
</CaptainGenerationDetails>
|
||||
<MessageMeta
|
||||
v-else
|
||||
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
|
||||
class="mt-2"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,6 +70,18 @@
|
||||
"RATING_TITLE": "Rating",
|
||||
"FEEDBACK_TITLE": "Feedback",
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"CAPTAIN_GENERATION": {
|
||||
"TITLE": "How was this reply generated?",
|
||||
"LOADING": "Loading details…",
|
||||
"EMPTY": "No generation details available for this message.",
|
||||
"REASONING": "Reasoning",
|
||||
"SOURCES": "Knowledge base",
|
||||
"SOURCES_SUMMARY": "Found {count} results",
|
||||
"SEARCHED_FOR": "Searched for “{query}”",
|
||||
"USED": "Used in reply",
|
||||
"TOOLS": "Tools used",
|
||||
"MODEL": "Generated with {model}"
|
||||
},
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels",
|
||||
|
||||
Reference in New Issue
Block a user