Files

343 lines
11 KiB
Vue

<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>