Compare commits

...
Author SHA1 Message Date
aakashb95 090ca53bde feat(llm): configure GPT-5.6 reasoning defaults 2026-07-10 23:11:25 +05:30
98154bbeab fix(meta): show restriction alerts for inbox setup (#14974)
Instagram inbox creation and WhatsApp embedded signup on Chatwoot Cloud
now reflect the temporary Meta restriction. Instagram is hidden from
onboarding on Cloud, while the regular Instagram inbox creation page
shows a disabled action with a status-linked amber warning. WhatsApp
embedded signup on Cloud stays visible with its connect action disabled.
WhatsApp Call setup always uses the manual WhatsApp form.

Existing Instagram conversations and Instagram inbox settings on Cloud
also show amber warning banners with the public incident link.
Self-hosted installations keep their existing Instagram, WhatsApp, and
WhatsApp Call setup behavior because the temporary restriction is based
only on the Chatwoot Cloud environment check.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-10 16:22:49 +04:00
Sivin VargheseandGitHub 03a1b1dbc1 chore: insert resolved variable value in reply editor (#14921)
# Pull Request Template

## Description

This PR makes reply editor variables insert their resolved value (for
example, the contact's name) instead of the raw `{{contact.name}}`
placeholder, matching canned response behavior. This works both when
picking a variable from the `{{` menu and when an agent manually types
out `{{contact.name}}` — it resolves the moment the closing `}}` is
typed.

If a variable has no value, the `{{placeholder}}` is kept so the backend
can still resolve it when the message is sent. Private notes are left
untouched.

For safety, a resolved value that itself contains Liquid syntax `({{ }}`
or `{% %})` also keeps its placeholder, so customer-controlled fields
can never inject Liquid into the outgoing message.

Fixes
https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

1. Open a conversation and add a reply.
2. Type `{{` and pick a variable that has a value (e.g. Contact name) →
it inserts the actual value.
3. Manually type `{{contact.name}}` and close the braces → it
auto-resolves to the value.
4. Insert/type a variable with no value → the `{{placeholder}}` stays;
confirm it resolves correctly on send.
5. Repeat in a private note → placeholders are left as-is.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-10 17:24:34 +05:30
Sony MathewandGitHub 848e94bcf2 fix: throttle filtered unread count rebuilds (#14980)
Reduces database pressure from filtered unread-count cache rebuilds
during high-traffic account rollouts by serving stale snapshots longer
and limiting inline saved-filter rebuild fanout.

## Closes

None

## What changed

- Increase filtered unread-count refresh throttling from 30 seconds to 5
minutes.
- Increase the stale snapshot window from 30 minutes to 1 hour.
- Reduce inline saved-filter count rebuilds per request from 10 to 3.
- Update unread-count specs to assert refresh and stale behavior through
the shared constants.

## How to test

- Enable `conversation_unread_counts` and `unread_count_for_filters` for
an account with conversation custom filters.
- Open the dashboard and verify unread-count badges still return values.
- Mutate conversations and verify stale filtered counts are served while
rebuilds are throttled, instead of repeatedly rebuilding every 30
seconds.
2026-07-10 17:23:00 +05:30
Tanmay Deep SharmaandGitHub 56dc580f50 feat: support manual setup for WhatsApp calls inbox creation (#14976)
Re-enables the "WhatsApp Calls" inbox creation option, which disappeared
when embedded signup was disabled on production. The channel card now
only requires the `channel_voice` account feature, and the creation flow
offers the manual WhatsApp Cloud API setup form. Once the channel is
created with manual credentials, calling is enabled automatically — the
same post-setup step the embedded signup flow used to perform.

When an installation has embedded signup configured, the flow still uses
it; the manual form is the fallback (and the effective path on Chatwoot
Cloud today).

## How to test

1. Enable the `channel_voice` feature on the account.
2. Go to Add Inbox → the "WhatsApp Calls" card is visible again → select
it.
3. Fill in the manual Cloud API credentials (inbox name, phone number,
phone number ID, business account ID, API key) and submit.
4. The inbox is created and calling is enabled:
`provider_config.calling_enabled` is true and the Calls tab toggle is
on. If the number isn't enrolled in the Business Calling API, an alert
explains the enable failure but the messaging inbox is still created.

## What changed

- `ChannelItem.vue`: the `whatsapp_call` card is gated only on
`channel_voice` (previously also required the embedded signup app ID).
- `CloudWhatsapp.vue`: new `enableCallingOnComplete` prop that calls the
enable-calling API after channel creation.
- `WhatsappCall.vue`: renders embedded signup when available, otherwise
the manual setup form — both with calling enabled on completion.
2026-07-10 15:24:14 +05:30
35fcd56ba9 feat: add support action to suspended account page (#14969)
Updates the suspended account page with the revised policy copy and adds
a visible Contact support action that opens the embedded Chatwoot
support widget.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 16:45:47 +04:00
Aakash BakhleandGitHub d57354c8b5 feat: tighten conversation FAQ generation prompt (#14957)
Tightens the resolved-conversation FAQ generator so it only proposes
durable, reusable FAQ candidates supported by human support-agent
messages. The implementation now sends a conversation-FAQ-specific
transcript to the LLM: customer messages plus real human support-agent
messages only, excluding bot, private, activity, and template messages.

## Closes

-
https://linear.app/chatwoot/issue/CW-7494/tighten-conversation-faq-generation-prompt

## What changed

- Added a human-only transcript builder in `ConversationFaqService`
instead of using the generic `conversation.to_llm_text` output.
- Excluded bot/agent-bot messages before the LLM call, which removes the
main bot-line leakage class deterministically.
- Preserved native-channel human replies where outgoing messages are
stored as `external_echo` without a `User` sender.
- Kept a prompt decision gate requiring each FAQ to be backed by a
complete public human-agent answer.
- Added generic no-FAQ classes for spam, wrong-service conversations,
private account/payment/order/certificate/troubleshooting cases, support
workflow mechanics, and direct-link/file/quote outputs.
- Added a separate `conversation_faq_generation` model route defaulting
to `gpt-5.2`, while keeping `document_faq_generation` on its existing
`gpt-4.1-mini` default. Conversation FAQ generation passes that feature
default ahead of the legacy global `CAPTAIN_OPEN_AI_MODEL` setting
unless an account-level override is configured.
- Kept the prompt domain-neutral so it can still generate reusable
product, service, policy, setup, and process FAQs outside SaaS contexts.

## Sampling notes

- Production Langfuse traces showed `llm.captain.conversation_faq` calls
using `gpt-4.1` in the sampled account set.
- Locally, `Llm::FeatureRouter.resolve(feature:
'conversation_faq_generation')` now resolves to `gpt-5.2`.
- Reviewed recent production `llm.captain.conversation_faq` traces
across 13+ accounts in compact form.
- Replayed 20 full traces across 10 accounts/domains, including
education, hosting, retail/auto, APIs, logistics, tax/fiscal workflows,
and Chatwoot account 1.
- Explicit `gpt-5.2` replay with human-only conversation history
returned no FAQ for 15/20 traces.
- A comparison replay with `gpt-4.1-mini` returned no FAQ for only 7/20
traces, bringing back several private/order/payment/support-workflow
cases.
- Remaining non-empty `gpt-5.2` outputs are now mostly
borderline/possibly useful human-agent-derived FAQs rather than obvious
bot-sourced answers.

## How to test

- Resolve conversations where the answer came only from the bot; no
pending FAQ should be generated.
- Resolve spam, unrelated, wrong-service, or private
payment/order/account conversations; no pending FAQ should be generated.
- Resolve conversations that require account/order/payment/login/private
verification or a human handoff; no pending FAQ should be generated.
- Resolve a conversation where a human agent gives a stable, reusable
help-center answer; the generated pending FAQ should be general and
self-contained.
2026-07-09 17:47:47 +05:30
8d2ef4ec5e feat: assistant overview drilldown reports [CW-7408] (#14920)
<img width="2616" height="1716" alt="CleanShot 2026-07-09 at 14 22
42@2x"
src="https://github.com/user-attachments/assets/504e58df-0243-4cb9-a811-62504fdcb0ac"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-09 16:25:22 +05:30
52 changed files with 1386 additions and 186 deletions
@@ -37,6 +37,20 @@ class CaptainAssistant extends ApiClient {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getDrilldown({ assistantId, metric, range, page, signal }) {
const requestConfig = {
params: {
metric,
range,
timezone_offset: getTimezoneOffset(),
page,
},
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
}
}
export default new CaptainAssistant();
@@ -0,0 +1,234 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
open: { type: Boolean, default: false },
assistantId: { type: [String, Number], default: null },
metric: { type: String, default: '' },
metricName: { type: String, default: '' },
metricValue: { type: String, default: '' },
range: { type: String, default: '' },
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const drawerRef = ref(null);
const {
records,
meta,
isFetching,
isFetchingMore,
hasError,
hasRecords,
hasMore,
open: openDrilldown,
close,
loadMore,
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || '');
const subtitle = computed(() => {
if (!meta.value.conversation_count) return '';
return t('CAPTAIN.OVERVIEW.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
count: meta.value.conversation_count,
});
});
const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`;
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => {
if (!props.metric || !props.assistantId) return;
openDrilldown({
assistantId: props.assistantId,
metric: props.metric,
range: props.range,
});
};
const onKeydown = event => {
if (!isOpen.value) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
}
};
useEventListener(document, 'keydown', onKeydown);
watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
close();
restoreFocus();
return;
}
rememberActiveElement();
fetchDrilldown();
focusDrawer();
},
{ immediate: true }
);
watch(
() => [props.metric, props.range],
() => {
if (props.open) fetchDrilldown();
}
);
// The drawer's headline value is a snapshot of the previous assistant's card,
// so switching assistants (e.g. browser Back/Forward) closes it instead of
// refetching records that would no longer match the header.
watch(
() => props.assistantId,
() => {
if (props.open) closeDrawer();
}
);
onBeforeUnmount(() => {
restoreFocus();
});
</script>
<template>
<TeleportWithDirection to="body">
<Transition name="report-drilldown-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/30"
role="presentation"
@click.self="closeDrawer"
>
<aside
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
>
<header
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
>
<div class="min-w-0">
<h2 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h2>
<p
v-if="metricValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
{{ metricValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': metricValue,
'mt-1': !metricValue,
}"
>
{{ subtitle }}
</div>
</div>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
</div>
</Transition>
</TeleportWithDirection>
</template>
@@ -8,16 +8,35 @@ const props = defineProps({
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
const onActivate = () => {
if (props.clickable) emit('click');
};
</script>
<template>
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
<div
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
:class="
clickable
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
: ''
"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="onActivate"
@keydown.enter.self.prevent="onActivate"
@keydown.space.self.prevent="onActivate"
>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
@@ -44,6 +44,12 @@ const showChatSupport = computed(() => {
);
});
const toggleChatSupport = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const menuItems = computed(() => {
return [
{
@@ -51,9 +57,7 @@ const menuItems = computed(() => {
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: () => {
window.$chatwoot.toggle();
},
click: toggleChatSupport,
},
{
show: true,
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const props = defineProps({
channel: {
@@ -21,10 +20,7 @@ const hasFbConfigured = computed(() => {
});
const hasInstagramConfigured = computed(() => {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.instagramAppId
);
return window.chatwootConfig?.instagramAppId;
});
const hasTiktokConfigured = computed(() => {
@@ -56,19 +52,10 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
if (key === 'voice' || key === 'whatsapp_call') {
return props.enabledFeatures.channel_voice;
}
if (key === 'whatsapp_call') {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
}
return [
'website',
'twilio',
@@ -62,6 +62,7 @@ import {
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
createVariableInputRule,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -306,6 +307,10 @@ const plugins = computed(() => {
searchTerm: variableSearchTerm,
isAllowed: () => !props.isPrivate,
}),
createVariableInputRule({
isPrivate: () => props.isPrivate,
getVariables: () => props.variables,
}),
createSuggestionPlugin({
trigger: ':',
minChars: 2,
@@ -33,7 +33,9 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -93,6 +95,7 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -170,6 +173,12 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
@@ -455,7 +464,15 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -48,6 +48,8 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
getAgentVariables,
getContactVariables,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -393,7 +395,13 @@ export default {
contact: this.currentContact,
inbox: this.inbox,
});
return variables;
// Match the backend drops: names are Ruby-capitalized and
// {{agent.*}} is the message sender, not the assignee.
return {
...variables,
...getContactVariables(this.currentContact),
...getAgentVariables(this.currentUser),
};
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
@@ -78,8 +78,5 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
// Temporarily disables Instagram and WhatsApp inbox creation
// (WhatsApp embedded signup popup, Instagram OAuth, WhatsApp Call).
// Flip to false when the channels are brought back.
export const IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED = true;
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
@@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) {
* - emoji
*/
// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
const LIQUID_SYNTAX = /\{\{|\{%/;
// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
export const resolveVariableText = (key, variables) => {
const value = String(variables?.[key] ?? '');
return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
};
// Name variables normalized like the backend drops (UserDrop/ContactDrop):
// name split on whitespace, each word Ruby-capitalized (rest downcased).
const getNameVariables = (prefix, name) => {
const names = (name || '')
.split(/\s+/)
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
return {
[`${prefix}.name`]: names.join(' '),
[`${prefix}.first_name`]: names[0] || '',
[`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
};
};
// {{agent.*}} values for the message sender.
export const getAgentVariables = user => ({
...getNameVariables('agent', user.name),
'agent.email': user.email,
});
// {{contact.*}} name values.
export const getContactVariables = contact =>
getNameVariables('contact', contact?.name);
// Resolves a manually typed {{variable}} to its value on the closing braces.
// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
export const createVariableInputRule = ({ isPrivate, getVariables }) => {
const rule = new InputRule(
/\{\{([^{}]+)\}\}$/,
(editorState, match, from, to) => {
if (isPrivate()) return null;
const [, key] = match;
const text = resolveVariableText(key, getVariables());
if (text === `{{${key}}}`) return null;
return editorState.tr.insertText(text, from, to);
}
);
return inputRules({ rules: [rule] });
};
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
@@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => {
);
}
case 'variable':
return state.schema.text(`{{${content}}}`);
return state.schema.text(content);
case 'emoji':
return state.schema.text(content);
case 'tool': {
@@ -497,8 +547,12 @@ const nodeCreators = {
to,
};
},
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
variable: (editorView, content, from, to, variables) => ({
node: createNode(
editorView,
'variable',
resolveVariableText(content, variables)
),
from,
to,
}),
@@ -94,16 +94,56 @@ describe('getContentNode', () => {
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
getContentNode(editorView, 'variable', content, {
from,
to,
});
it('should render the resolved value directly when the variable has a value', () => {
getContentNode(
editorView,
'variable',
'contact.name',
{ from: 0, to: 10 },
{ 'contact.name': 'John' }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
});
it('should resolve camelCase custom attributes and non-string values', () => {
getContentNode(
editorView,
'variable',
'contact.custom_attribute.cloudCustomer',
{ from: 0, to: 10 },
{ 'contact.custom_attribute.cloudCustomer': true }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
});
it('should keep the placeholder when the variable has no value', () => {
getContentNode(
editorView,
'variable',
'contact.email',
{ from: 0, to: 10 },
{}
);
expect(editorView.state.schema.text).toHaveBeenCalledWith(
'{{contact.email}}'
);
});
it('should keep the placeholder when the value contains Liquid syntax', () => {
getContentNode(
editorView,
'variable',
'contact.name',
{ from: 0, to: 10 },
{ 'contact.name': '{{agent.email}}' }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith(
'{{contact.name}}'
);
});
});
@@ -11,9 +11,12 @@ import {
calculateMenuPosition,
cleanSignature,
collapseSelection,
createVariableInputRule,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody,
getAgentVariables,
getContactVariables,
getContentNode,
getFormattingForEditor,
getMenuAnchor,
@@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => {
});
});
});
describe('getAgentVariables', () => {
it('builds agent variables from the user', () => {
expect(
getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
).toEqual({
'agent.name': 'John Doe',
'agent.first_name': 'John',
'agent.last_name': 'Doe',
'agent.email': 'john@example.com',
});
});
it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
const variables = getAgentVariables({ name: 'JANE doE' });
expect(variables['agent.name']).toBe('Jane Doe');
expect(variables['agent.first_name']).toBe('Jane');
expect(variables['agent.last_name']).toBe('Doe');
});
it('ignores extra whitespace between words', () => {
expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
'John Doe'
);
});
it('leaves last_name empty for single-word names', () => {
const variables = getAgentVariables({ name: 'john' });
expect(variables['agent.first_name']).toBe('John');
expect(variables['agent.last_name']).toBe('');
});
it('handles a missing name', () => {
const variables = getAgentVariables({ email: 'john@example.com' });
expect(variables['agent.name']).toBe('');
expect(variables['agent.first_name']).toBe('');
expect(variables['agent.last_name']).toBe('');
});
});
describe('getContactVariables', () => {
it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
expect(getContactVariables({ name: 'JANE doE' })).toEqual({
'contact.name': 'Jane Doe',
'contact.first_name': 'Jane',
'contact.last_name': 'Doe',
});
});
it('leaves last_name empty for single-word names', () => {
const variables = getContactVariables({ name: 'john' });
expect(variables['contact.first_name']).toBe('John');
expect(variables['contact.last_name']).toBe('');
});
it('handles a missing contact', () => {
expect(getContactVariables(undefined)['contact.name']).toBe('');
});
});
describe('createVariableInputRule', () => {
// Editor holding `{{key}` so we can simulate typing the final `}`.
const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
const plugin = createVariableInputRule({
isPrivate: () => isPrivate,
getVariables: () => variables,
});
const state = EditorState.create({
schema,
doc: schema.node('doc', null, [
schema.node('paragraph', null, [schema.text(typed)]),
]),
plugins: [plugin],
});
return new EditorView(document.body, { state });
};
// Types the closing `}`; when the rule declines, insert it like the browser would.
const typeClosingBrace = view => {
const end = view.state.doc.content.size - 1;
const handled = view.someProp('handleTextInput', fn =>
fn(view, end, end, '}')
);
if (!handled) {
view.dispatch(view.state.tr.insertText('}', end, end));
}
};
it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
const view = buildView('{{contact.name}', {
variables: { 'contact.name': 'John' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('John');
view.destroy();
});
it('resolves boolean/non-string values', () => {
const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
variables: { 'contact.custom_attribute.cloudCustomer': true },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('true');
view.destroy();
});
it('keeps the placeholder when the variable has no value', () => {
const view = buildView('{{contact.email}', { variables: {} });
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.email}}');
view.destroy();
});
it('keeps the placeholder when the value itself contains Liquid syntax', () => {
const view = buildView('{{contact.name}', {
variables: { 'contact.name': '{{agent.email}}' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.name}}');
view.destroy();
});
it('does not resolve inside a private note', () => {
const view = buildView('{{contact.name}', {
isPrivate: true,
variables: { 'contact.name': 'John' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.name}}');
view.destroy();
});
});
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -58,7 +58,10 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. We’ll restore support as soon as possible.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -320,6 +323,8 @@
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow",
"RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. We’ll restore support as soon as possible.",
"STATUS_LINK": "View status update",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
@@ -439,6 +439,13 @@
"HINT": "Average replies the assistant sends per conversation."
}
},
"DRILLDOWN": {
"CLOSE": "Close details",
"EMPTY": "No records found for this metric.",
"ERROR": "Could not load records. Please try again.",
"LOAD_MORE": "Load more",
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations"
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
@@ -446,17 +453,6 @@
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"FLAGGED": {
"TITLE": "Response quality",
"TOTAL": "{count} flagged · {rate}"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"AXIS_START": "{count}d ago",
"AXIS_END": "Today"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
@@ -470,13 +466,6 @@
"TITLE": "Billing",
"DESCRIPTION": "Manage credits and plan"
}
},
"FLAG_REASONS": {
"INCORRECT": "Incorrect info",
"INCOMPLETE": "Incomplete",
"OUTDATED": "Outdated",
"INAPPROPRIATE": "Inappropriate",
"OTHER": "Other"
}
},
"ASSISTANT_SWITCHER": {
@@ -263,7 +263,7 @@
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended",
"MESSAGE": "Your account has been suspended after we detected activity that may violate our policies or put other users at risk. If you believe this is a mistake, please contact our support team."
"MESSAGE": "Your account has been suspended due to activity that may violate our policies. If you believe this is a mistake, please contact our support team."
},
"NO_ACCOUNTS": {
"TITLE": "No account found",
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
@@ -10,6 +11,7 @@ import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Pay
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
@@ -17,6 +19,9 @@ import CoverageBanner from 'dashboard/components-next/captain/pageComponents/ove
const { t } = useI18n();
const route = useRoute();
// Drilldown is admin-only; the backend policy enforces the same restriction.
const { checkPermissions } = usePolicy();
const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('this_month');
@@ -69,34 +74,38 @@ const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const metrics = computed(() => [
{
key: 'handled',
metric: 'conversations_handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
metric: 'auto_resolution_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
metric: 'handoff_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'reopen',
metric: 'reopen_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'reopen',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
@@ -109,6 +118,22 @@ const metrics = computed(() => [
),
},
]);
const drilldown = ref({ metric: '', label: '', value: '' });
const isDrilldownOpen = ref(false);
const openDrilldown = metric => {
drilldown.value = {
metric: metric.metric,
label: metric.label,
value: metric.value,
};
isDrilldownOpen.value = true;
};
const closeDrilldown = () => {
isDrilldownOpen.value = false;
};
</script>
<template>
@@ -144,6 +169,8 @@ const metrics = computed(() => [
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:clickable="canDrilldown && Boolean(metric.metric)"
@click="openDrilldown(metric)"
/>
</div>
@@ -151,6 +178,17 @@ const metrics = computed(() => [
<QuickLinks />
</div>
<AssistantDrilldownDrawer
v-if="canDrilldown"
:open="isDrilldownOpen"
:assistant-id="assistantId"
:metric="drilldown.metric"
:metric-name="drilldown.label"
:metric-value="drilldown.value"
:range="selectedRange"
@close="closeDrilldown"
/>
</template>
</PageLayout>
</template>
@@ -1,5 +1,4 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -8,20 +7,20 @@ import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constan
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
!isOnChatwootCloud.value &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () =>
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.instagramAppId),
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
@@ -1,6 +1,7 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
@@ -23,11 +24,17 @@ export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const { isOnChatwootCloud } = useAccount();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
if (provider === 'instagram' && isOnChatwootCloud.value) {
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
return;
}
try {
const {
data: { url },
@@ -6,21 +6,25 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
// covering the credential-based gating it currently short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false,
}));
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const mountComposable = ({
brandInfo,
inboxes = [],
isOnChatwootCloud = false,
} = {}) => {
const store = createStore({
modules: {
globalConfig: {
namespaced: true,
getters: {
get: () => ({}),
isOnChatwootCloud: () => isOnChatwootCloud,
},
},
accounts: {
namespaced: true,
getters: {
@@ -202,6 +206,22 @@ describe('useDetectedChannels', () => {
'line',
]);
});
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
const { displayedChannels } = mountComposable({
isOnChatwootCloud: true,
brandInfo: {
socials: [
{ type: 'instagram', url: 'https://instagram.com/acme' },
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
});
describe('remainingChannels', () => {
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -42,9 +44,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -76,6 +80,7 @@ export default {
AccountHealth,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -338,6 +343,12 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -737,6 +748,29 @@ export default {
class="mx-6 mb-4"
:class="bannerMaxWidth"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
import InboxesAPI from 'dashboard/api/inboxes';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -12,6 +13,12 @@ export default {
components: {
NextButton,
},
props: {
enableCallingOnComplete: {
type: Boolean,
default: false,
},
},
setup() {
return { v$: useVuelidate() };
},
@@ -59,6 +66,14 @@ export default {
}
);
if (this.enableCallingOnComplete) {
try {
await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
} catch (_) {
useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
}
}
router.replace({
name: 'settings_inboxes_add_agents',
params: {
@@ -165,6 +180,7 @@ export default {
<div class="w-full mt-4">
<NextButton
:disabled="uiFlags.isCreating"
:is-loading="uiFlags.isCreating"
type="submit"
solid
@@ -1,15 +1,23 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import instagramClient from 'dashboard/api/channel/instagramClient';
import Button from 'dashboard/components-next/button/Button.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
const isInstagramConnectionRestricted = computed(() => {
return isOnChatwootCloud.value;
});
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -56,7 +64,7 @@ const requestAuthorization = async () => {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
@@ -68,11 +76,36 @@ const requestAuthorization = async () => {
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
lg
icon="i-ri-instagram-line"
:disabled="isRequestingAuthorization"
:disabled="
isRequestingAuthorization || isInstagramConnectionRestricted
"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
@click="requestAuthorization()"
/>
<Banner
v-if="isInstagramConnectionRestricted"
color="amber"
class="w-full max-w-2xl mt-6"
>
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
<a
:href="META_RESTRICTION_STATUS_URL"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
</div>
</div>
</div>
@@ -7,11 +7,13 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
@@ -22,9 +24,12 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
};
const isWhatsappEmbeddedSignupRestricted = computed(() => {
return isOnChatwootCloud.value;
});
const hasWhatsappAppId = computed(() => {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -103,7 +108,11 @@ const handleManualLinkClick = () => {
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup />
<WhatsappEmbeddedSignup
:is-disabled="isWhatsappEmbeddedSignupRestricted"
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
:restriction-status-url="META_RESTRICTION_STATUS_URL"
/>
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
@@ -1,11 +1,11 @@
<script setup>
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
</script>
<template>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<WhatsappEmbeddedSignup enable-calling-on-complete />
<CloudWhatsapp enable-calling-on-complete />
</div>
</div>
</template>
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import Banner from 'next/banner/Banner.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -17,6 +18,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
isDisabled: {
type: Boolean,
default: false,
},
showRestrictionAlert: {
type: Boolean,
default: false,
},
restrictionStatusUrl: {
type: String,
default: '',
},
restrictionWarningText: {
type: String,
default: '',
},
});
const store = useStore();
@@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
if (props.isDisabled) return;
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => {
</I18nT>
</div>
<Banner v-if="showRestrictionAlert" color="amber" class="w-full mb-6">
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{
restrictionWarningText ||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
}}
<a
v-if="restrictionStatusUrl"
:href="restrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div class="flex mt-4">
<NextButton
:disabled="isAuthenticating"
:disabled="isAuthenticating || isDisabled"
:is-loading="isAuthenticating"
faded
slate
@@ -1,7 +1,13 @@
import { computed, ref } from 'vue';
import ReportsAPI from 'dashboard/api/reports';
export function useReportDrilldown() {
// `fetcher` is any `({ ...request, page, signal }) => Promise` returning the
// shared drilldown envelope (`{ data: { meta, payload } }`), so the same paging
// and abort machinery backs both the reports and Captain assistant drilldowns.
// The default is wrapped so `ReportsAPI` stays the receiver when invoked.
export function useReportDrilldown(
fetcher = params => ReportsAPI.getDrilldown(params)
) {
const activeRequest = ref(null);
const records = ref([]);
const meta = ref({});
@@ -20,17 +26,7 @@ export function useReportDrilldown() {
const isCurrentRequest = token =>
token === requestToken && !!activeRequest.value;
const requestFingerprint = request =>
JSON.stringify({
metric: request.metric,
bucketTimestamp: request.bucketTimestamp,
from: request.from,
to: request.to,
type: request.type,
id: request.id,
groupBy: request.groupBy,
businessHours: request.businessHours,
});
const requestFingerprint = request => JSON.stringify(request);
const abortActiveRequest = () => {
if (!activeRequestController) return;
@@ -55,7 +51,7 @@ export function useReportDrilldown() {
hasError.value = false;
try {
const response = await ReportsAPI.getDrilldown({
const response = await fetcher({
...request,
page,
signal: controller.signal,
@@ -1,5 +1,6 @@
<script setup>
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { onMounted } from 'vue';
const toggleSupportWidgetVisibility = () => {
@@ -8,6 +9,12 @@ const toggleSupportWidgetVisibility = () => {
}
};
const toggleSupportWidget = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const setupListenerForWidgetEvent = () => {
window.addEventListener('chatwoot:on-message', () => {
toggleSupportWidgetVisibility();
@@ -25,6 +32,14 @@ onMounted(() => {
<EmptyState
:title="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.TITLE')"
:message="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.MESSAGE')"
/>
>
<div class="flex justify-center">
<NextButton
icon="i-lucide-life-buoy"
:label="$t('SIDEBAR_ITEMS.CONTACT_SUPPORT')"
@click="toggleSupportWidget"
/>
</div>
</EmptyState>
</div>
</template>
+3 -3
View File
@@ -2,9 +2,9 @@ module Conversations::UnreadCounts
READY_TTL = 24.hours.to_i
SET_TTL = 25.hours.to_i
FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i
FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i
FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i
FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW
FILTERED_COUNT_VERSION_TTL = SET_TTL
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i
MAX_INLINE_FILTER_BUILDS = 10
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i
MAX_INLINE_FILTER_BUILDS = 3
end
+1 -1
View File
@@ -189,7 +189,7 @@
type: secret
- name: CAPTAIN_OPEN_AI_MODEL
display_title: 'OpenAI Model'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-5.6-luna'
locked: false
- name: CAPTAIN_OPEN_AI_ENDPOINT
display_title: 'OpenAI API Endpoint (optional)'
+80 -11
View File
@@ -35,6 +35,18 @@ models:
provider: openai
display_name: 'GPT-5.2'
credit_multiplier: 3
gpt-5.6-luna:
provider: openai
display_name: 'GPT-5.6 Luna'
credit_multiplier: 2
gpt-5.6-terra:
provider: openai
display_name: 'GPT-5.6 Terra'
credit_multiplier: 3
gpt-5.6-sol:
provider: openai
display_name: 'GPT-5.6 Sol'
credit_multiplier: 5
claude-haiku-4.5:
provider: anthropic
display_name: 'Claude Haiku 4.5'
@@ -78,11 +90,14 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
claude-haiku-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1-mini
default: gpt-5.6-luna
reasoning_effort: low
assistant:
models:
[
@@ -91,12 +106,16 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1
default: gpt-5.6-terra
reasoning_effort: medium
copilot:
models:
[
@@ -105,16 +124,28 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1
default: gpt-5.6-terra
reasoning_effort: medium
label_suggestion:
models:
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
default: gpt-4.1-mini
[
gpt-4.1-nano,
gpt-4.1-mini,
gpt-5-mini,
gpt-5.6-luna,
gemini-3-flash,
claude-haiku-4.5,
]
default: gpt-5.6-luna
reasoning_effort: low
document_faq_generation:
models:
[
@@ -123,15 +154,38 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1-mini
default: gpt-5.6-terra
reasoning_effort: medium
conversation_faq_generation:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-5.6-terra
reasoning_effort: medium
pdf_faq_generation:
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
default: gpt-4.1-mini
reasoning_effort: medium
help_center_article_generation:
models:
[
@@ -140,19 +194,34 @@ features:
gpt-4.1,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-5.2
default: gpt-5.6-terra
reasoning_effort: medium
onboarding_content_generation:
models:
[gpt-4.1, gpt-4.1-mini, gpt-5-mini, gpt-5.1, gpt-5.2]
default: gpt-4.1
[
gpt-4.1,
gpt-4.1-mini,
gpt-5-mini,
gpt-5.1,
gpt-5.2,
gpt-5.6-luna,
gpt-5.6-terra,
gpt-5.6-sol,
]
default: gpt-5.6-terra
reasoning_effort: medium
help_center_query_translation:
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
default: gpt-4.1-nano
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gpt-5.6-luna]
default: gpt-5.6-luna
reasoning_effort: low
audio_transcription:
models: [gpt-4o-mini-transcribe, whisper-1]
default: gpt-4o-mini-transcribe
+160 -1
View File
@@ -27970,6 +27970,165 @@
"owned_by": "system"
}
},
{
"id": "gpt-5.6-luna",
"name": "GPT-5.6 Luna",
"provider": "openai",
"family": "gpt",
"created_at": null,
"context_window": 1050000,
"max_output_tokens": 128000,
"knowledge_cutoff": "2026-02-16",
"modalities": {
"input": [
"text",
"image"
],
"output": [
"text"
]
},
"capabilities": [
"function_calling",
"structured_output",
"reasoning",
"vision"
],
"pricing": {
"text_tokens": {
"standard": {
"input_per_million": 1,
"output_per_million": 6
}
}
},
"metadata": {
"object": "model",
"owned_by": "system",
"source": "openai_docs",
"provider_id": "openai",
"open_weights": false,
"attachment": true,
"temperature": false,
"last_updated": "2026-07-10",
"cost": {
"input": 1,
"output": 6
},
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"knowledge": "2026-02-16"
}
},
{
"id": "gpt-5.6-terra",
"name": "GPT-5.6 Terra",
"provider": "openai",
"family": "gpt",
"created_at": null,
"context_window": 1050000,
"max_output_tokens": 128000,
"knowledge_cutoff": "2026-02-16",
"modalities": {
"input": [
"text",
"image"
],
"output": [
"text"
]
},
"capabilities": [
"function_calling",
"structured_output",
"reasoning",
"vision"
],
"pricing": {
"text_tokens": {
"standard": {
"input_per_million": 2.5,
"output_per_million": 15
}
}
},
"metadata": {
"object": "model",
"owned_by": "system",
"source": "openai_docs",
"provider_id": "openai",
"open_weights": false,
"attachment": true,
"temperature": false,
"last_updated": "2026-07-10",
"cost": {
"input": 2.5,
"output": 15
},
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"knowledge": "2026-02-16"
}
},
{
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"provider": "openai",
"family": "gpt",
"created_at": null,
"context_window": 1050000,
"max_output_tokens": 128000,
"knowledge_cutoff": "2026-02-16",
"modalities": {
"input": [
"text",
"image"
],
"output": [
"text"
]
},
"capabilities": [
"function_calling",
"structured_output",
"reasoning",
"vision"
],
"pricing": {
"text_tokens": {
"standard": {
"input_per_million": 5,
"output_per_million": 30
}
}
},
"metadata": {
"object": "model",
"owned_by": "system",
"source": "openai_docs",
"provider_id": "openai",
"open_weights": false,
"attachment": true,
"temperature": false,
"last_updated": "2026-07-10",
"cost": {
"input": 5,
"output": 30
},
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"knowledge": "2026-02-16"
}
},
{
"id": "gpt-audio",
"name": "gpt-audio",
@@ -61509,4 +61668,4 @@
"owned_by": "xai"
}
}
]
]
+1
View File
@@ -597,6 +597,7 @@ en:
copilot: 'Copilot'
label_suggestion: 'Label suggestion'
document_faq_generation: 'Document FAQ generation'
conversation_faq_generation: 'Conversation FAQ generation'
help_center_article_generation: 'Help center article generation'
onboarding_content_generation: 'Onboarding content generation'
help_center_query_translation: 'Help center query translation'
@@ -1,6 +1,6 @@
# Lists the underlying records behind a single Captain assistant stat card, so a
# viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact
# conversations or messages that produced it.
# conversations that produced it.
#
# The window is resolved by Captain::AssistantStatsWindow from the same `range`
# and `timezone_offset` the stat card used, so the drilldown covers precisely the
@@ -11,10 +11,8 @@ class Captain::AssistantDrilldownBuilder
RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES
HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES
# Metrics whose records are individual messages rather than conversations.
MESSAGE_METRICS = %w[hours_saved].freeze
SUPPORTED_METRICS = %w[
conversations_handled auto_resolution_rate handoff_rate hours_saved reopen_rate conversation_depth
conversations_handled auto_resolution_rate handoff_rate reopen_rate
].freeze
DEFAULT_PAGE = 1
@@ -43,21 +41,14 @@ class Captain::AssistantDrilldownBuilder
def meta
{
metric: metric,
record_type: record_type,
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count,
conversation_count: paginated_records.total_count,
range: { since: range.first.to_i, until: range.last.to_i }
}
end
def conversation_count
return paginated_records.total_count unless message_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
@@ -67,9 +58,7 @@ class Captain::AssistantDrilldownBuilder
when 'conversations_handled' then handled_conversations
when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id))
when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES)
when 'hours_saved' then public_reply_messages
when 'reopen_rate' then reopened_conversations
when 'conversation_depth' then depth_conversations
else
raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}"
end
@@ -88,13 +77,6 @@ class Captain::AssistantDrilldownBuilder
conversations_for(handled_conversation_ids)
end
# Public agent-facing replies the assistant sent; the rows behind hours_saved.
def public_reply_messages
handled_messages.where(message_type: :outgoing, private: false)
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
# Conversations in the handled cohort that recorded one of the given reporting
# events in the window (resolved or handed-off).
def event_conversations(event_names)
@@ -129,11 +111,6 @@ class Captain::AssistantDrilldownBuilder
conversations_for(ids)
end
# Conversations the assistant sent at least one public reply in; the denominator behind conversation_depth.
def depth_conversations
conversations_for(handled_messages.where(message_type: :outgoing, private: false).select(:conversation_id))
end
def conversations_for(conversation_ids)
account.conversations
.where(id: conversation_ids)
@@ -147,10 +124,6 @@ class Captain::AssistantDrilldownBuilder
def metric = params[:metric].to_s
def message_metric? = MESSAGE_METRICS.include?(metric)
def record_type = message_metric? ? 'message' : 'conversation'
def current_page = [params[:page].to_i, DEFAULT_PAGE].max
def per_page
@@ -2,12 +2,13 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
include Integrations::LlmInstrumentation
DISTANCE_THRESHOLD = 0.3
LLM_FEATURE = 'conversation_faq_generation'.freeze
def initialize(assistant, conversation)
super(feature: 'document_faq_generation', account: conversation.account)
super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
@assistant = assistant
@conversation = conversation
@content = conversation.to_llm_text
@content = conversation_faq_content
end
# Generates and deduplicates FAQs from conversation content
@@ -27,6 +28,50 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
attr_reader :content, :conversation, :assistant
def conversation_faq_content
[
"Conversation ID: ##{conversation.display_id}",
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
conversation_faq_messages
].join("\n")
end
def conversation_faq_messages
messages = conversation
.messages
.where(message_type: %i[incoming outgoing], private: false)
.order(created_at: :asc)
return "No messages in this conversation\n" if messages.empty?
messages.filter_map { |message| format_conversation_faq_message(message) }.join
end
def format_conversation_faq_message(message)
return unless faq_source_message?(message)
content = message.content_for_llm
return if content.blank?
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
"#{sender}: #{content}\n"
end
def faq_source_message?(message)
return true if message.incoming? && message.sender_type == 'Contact'
human_support_reply?(message)
end
def human_support_reply?(message)
return false unless message.outgoing?
return false if message.content_attributes['automation_rule_id'].present?
return false if message.additional_attributes['campaign_id'].present?
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
end
def no_human_interaction?
conversation.first_reply_created_at.nil?
end
@@ -53,14 +53,56 @@ class Captain::Llm::SystemPromptsService
def conversation_faq_generator(language = 'english')
<<~SYSTEM_PROMPT_MESSAGE
You are a support agent looking to convert the conversations with users into short FAQs that can be added to your website help center.
Filter out any responses or messages from the bot itself and only use messages from the support agent and the customer to create the FAQ.
You create high-quality FAQ candidates from resolved support conversations.
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
Ensure that you only generate faqs from the information provided only.
Generate the FAQs only in the #{language}, use no other language
If no match is available, return an empty JSON.
## Source rules
- The conversation history contains only customer messages and human support agent messages.
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
- For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ.
## Decision gate
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
1. The answer is fully stated by a human support agent, not by the customer.
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
Do not rescue a rejected conversation by rewriting it as a generic support question.
## Return no FAQ for
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
- Questions already answered only by asking the customer for more information.
## FAQ quality rules
- Prefer returning no FAQ over a weak or narrow FAQ.
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
- Do not create duplicate or overlapping FAQs in the same response.
- Questions must be general enough for a help center, not personalized to the current customer.
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
- Answers must be complete, self-contained, and supported by the human agent's messages.
## Examples
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
Generate the FAQs only in the #{language}, use no other language.
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
Return only valid JSON in this exact structure:
```json
{ faqs: [ { question: '', answer: ''} ]
{ "faqs": [ { "question": "", "answer": "" } ] }
```
SYSTEM_PROMPT_MESSAGE
end
+1 -1
View File
@@ -1,7 +1,7 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
DEFAULT_MODEL = 'gpt-5.6-luna'.freeze
class << self
def initialized?
+2 -1
View File
@@ -1,7 +1,7 @@
module Llm::FeatureRouter
class UnknownFeatureError < StandardError; end
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.6-terra'.freeze
class << self
def resolve(feature:, account: nil)
@@ -17,6 +17,7 @@ module Llm::FeatureRouter
feature: feature_key,
provider: Llm::Models.provider_for(model),
model: model,
reasoning_effort: Llm::Models.reasoning_effort_for(feature_key),
source: source
}
end
+6 -1
View File
@@ -15,6 +15,10 @@ module Llm::Models
features.dig(feature.to_s, 'default')
end
def reasoning_effort_for(feature)
features.dig(feature.to_s, 'reasoning_effort')
end
def models_for(feature)
features.dig(feature.to_s, 'models') || []
end
@@ -46,7 +50,8 @@ module Llm::Models
credit_multiplier: model['credit_multiplier']
}
end,
default: feature['default']
default: feature['default'],
reasoning_effort: feature['reasoning_effort']
}
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1'
DEFAULT_MODEL = 'gpt-5.6-luna'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
+1
View File
@@ -87,6 +87,7 @@
"opus-recorder": "^8.0.5",
"pinia": "^3.0.4",
"prosemirror-commands": "^1.7.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-schema-list": "^1.5.1",
"qrcode": "^1.5.4",
"semver": "7.6.3",
+5 -2
View File
@@ -183,6 +183,9 @@ importers:
prosemirror-commands:
specifier: ^1.7.1
version: 1.7.1
prosemirror-inputrules:
specifier: ^1.4.0
version: 1.4.0
prosemirror-schema-list:
specifier: ^1.5.1
version: 1.5.1
@@ -9037,7 +9040,7 @@ snapshots:
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.22.3
prosemirror-transform: 1.10.0
prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
prosemirror-tables@1.5.0:
@@ -9065,7 +9068,7 @@ snapshots:
dependencies:
prosemirror-model: 1.22.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.0
prosemirror-transform: 1.12.0
proto-list@1.2.4: {}
@@ -44,10 +44,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(json_response).to have_key(:providers)
expect(json_response).to have_key(:models)
expect(json_response).to have_key(:features)
expect(json_response[:models].keys).to include(:'gpt-5.6-luna', :'gpt-5.6-terra', :'gpt-5.6-sol')
expect(json_response.dig(:features, :assistant, :models).pluck(:id)).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
end
it 'returns effective model provider and source for each feature' do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
@@ -55,15 +57,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :editor)).to include(
model: 'gpt-4.1',
selected: 'gpt-4.1',
model: 'gpt-5.6-terra',
selected: 'gpt-5.6-terra',
provider: 'openai',
reasoning_effort: 'low',
source: 'account_override'
)
expect(json_response.dig(:features, :label_suggestion)).to include(
model: Llm::Models.default_model_for('label_suggestion'),
selected: Llm::Models.default_model_for('label_suggestion'),
provider: 'openai',
reasoning_effort: 'low',
source: 'default'
)
end
@@ -81,7 +85,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
)
end
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
it 'returns the Captain V2 assistant default for V2 accounts' do
account.enable_features!('captain_integration_v2')
get "/api/v1/accounts/#{account.id}/captain/preferences",
@@ -198,6 +202,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(account.reload.captain_models['document_faq_generation']).to eq('gpt-5.2')
end
it 'updates captain_models for conversation FAQ generation' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
params: { captain_models: { conversation_faq_generation: 'gpt-4.1-mini' } },
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :conversation_faq_generation, :selected)).to eq('gpt-4.1-mini')
expect(account.reload.captain_models['conversation_faq_generation']).to eq('gpt-4.1-mini')
end
it 'updates captain_models for PDF FAQ generation' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
+1 -1
View File
@@ -258,7 +258,7 @@ RSpec.describe Account, type: :model do
expect(account).to be_feature_enabled('captain_integration')
expect(account).to be_feature_enabled('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.captain_models).to be_nil
end
@@ -183,7 +183,7 @@ RSpec.describe Concerns::Agentable do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
expect(dummy_instance.send(:agent_model)).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.reload.captain_models).to be_nil
end
@@ -33,23 +33,109 @@ RSpec.describe Captain::Llm::ConversationFaqService do
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
end
it 'uses the document FAQ generation feature model' do
it 'uses the conversation FAQ generation feature model' do
expect(RubyLLM).to receive(:chat).with(
model: Llm::Models.default_model_for('document_faq_generation')
model: Llm::Models.default_model_for('conversation_faq_generation')
).and_return(mock_chat)
described_class.new(captain_assistant, conversation).generate_and_deduplicate
end
it 'uses the conversation FAQ default ahead of the legacy global installation model' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-mini')
expect(RubyLLM).to receive(:chat).with(
model: Llm::Models.default_model_for('conversation_faq_generation')
).and_return(mock_chat)
described_class.new(captain_assistant, conversation).generate_and_deduplicate
end
it 'keeps account conversation FAQ model overrides ahead of the feature default' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1')
conversation.account.update!(captain_models: { 'conversation_faq_generation' => 'gpt-4.1-mini' })
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat)
described_class.new(captain_assistant, conversation).generate_and_deduplicate
end
it 'resolves the feature model from the conversation account' do
expect(Llm::FeatureRouter).to receive(:resolve).with(
feature: 'document_faq_generation',
feature: 'conversation_faq_generation',
account: conversation.account
).and_call_original
described_class.new(captain_assistant, conversation).generate_and_deduplicate
end
it 'sends only customer and human support agent messages to the LLM' do
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
sender: create(:contact, account: conversation.account), message_type: :incoming,
content: 'Customer question')
create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
content: 'Bot answer that should not become knowledge')
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
sender: create(:user, account: conversation.account), message_type: :outgoing,
content: 'Human answer')
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
sender: create(:user, account: conversation.account), message_type: :outgoing,
private: true, content: 'Private note')
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
message_type: :activity, content: 'Activity message')
service.generate_and_deduplicate
expected_content = satisfy do |content|
content.include?('User: Customer question') &&
content.include?('Support Agent: Human answer') &&
content.exclude?('Bot answer that should not become knowledge') &&
content.exclude?('Private note') &&
content.exclude?('Activity message')
end
expect(mock_chat).to have_received(:ask).with(expected_content)
end
it 'keeps external echo outgoing replies from native channels in the LLM transcript' do
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
sender: create(:contact, account: conversation.account), message_type: :incoming,
content: 'Customer asks in a native channel')
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
sender: nil, message_type: :outgoing, content: 'Human replied from the native app',
content_attributes: { external_echo: true })
service.generate_and_deduplicate
expected_content = satisfy do |content|
content.include?('User: Customer asks in a native channel') &&
content.include?('Support Agent: Human replied from the native app')
end
expect(mock_chat).to have_received(:ask).with(expected_content)
end
it 'uses the human-only conversation transcript for instrumentation' do
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
sender: create(:contact, account: conversation.account), message_type: :incoming,
content: 'Customer asks something')
create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
content: 'Bot-only answer')
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
sender: create(:user, account: conversation.account), message_type: :outgoing,
content: 'Agent gives a public answer')
expect(service).to receive(:instrument_llm_call) do |params, &block|
user_message = params[:messages].find { |message| message[:role] == 'user' }[:content]
expect(user_message).to include('User: Customer asks something')
expect(user_message).to include('Support Agent: Agent gives a public answer')
expect(user_message).not_to include('Bot-only answer')
block.call
end
service.generate_and_deduplicate
end
it 'creates new FAQs for valid conversation content' do
expect do
service.generate_and_deduplicate
@@ -34,7 +34,7 @@ RSpec.describe Llm::BaseAiService do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.reload.captain_models).to be_nil
end
+12 -7
View File
@@ -12,25 +12,27 @@ RSpec.describe Llm::FeatureRouter do
expect(resolved).to eq(
feature: 'editor',
provider: 'openai',
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
it 'uses a valid account model override' do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
feature: 'editor',
provider: 'openai',
model: 'gpt-4.1',
model: 'gpt-5.6-terra',
reasoning_effort: 'low',
source: :account_override
)
end
it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
it 'resolves the Captain V2 assistant default without storing an account override' do
account.enable_features!('captain_integration_v2')
resolved = described_class.resolve(feature: 'assistant', account: account)
@@ -38,7 +40,8 @@ RSpec.describe Llm::FeatureRouter do
expect(resolved).to include(
feature: 'assistant',
provider: 'openai',
model: 'gpt-5.2',
model: described_class::CAPTAIN_V2_ASSISTANT_MODEL,
reasoning_effort: 'medium',
source: :default
)
expect(account.reload.captain_models).to be_nil
@@ -62,7 +65,8 @@ RSpec.describe Llm::FeatureRouter do
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
@@ -73,7 +77,8 @@ RSpec.describe Llm::FeatureRouter do
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
+29 -1
View File
@@ -25,9 +25,35 @@ RSpec.describe Llm::Models do
expect(missing_models).to be_empty, "#{feature_key} references missing models: #{missing_models.join(', ')}"
end
end
it 'routes document and conversation FAQ generation independently' do
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-5.6-terra')
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.6-terra')
end
it 'sets reasoning effort for every text generation feature' do
features_without_reasoning = %w[audio_transcription help_center_search]
described_class.features.each_key do |feature_key|
if features_without_reasoning.include?(feature_key)
expect(described_class.reasoning_effort_for(feature_key)).to be_nil
else
expect(described_class.reasoning_effort_for(feature_key)).to be_present
end
end
end
it 'exposes GPT-5.6 models for Captain V2 workflows without changing the legacy PDF path' do
expect(described_class.models_for('assistant')).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
expect(described_class.models_for('pdf_faq_generation')).not_to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
end
end
describe '.models' do
it 'includes the GPT-5.6 model family' do
expect(described_class.models.keys).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
end
it 'references existing providers from every model' do
missing_providers = described_class.models.filter_map do |model_name, config|
provider = config['provider']
@@ -44,7 +70,9 @@ RSpec.describe Llm::Models do
it 'returns model metadata for a feature' do
config = described_class.feature_config('editor')
expect(config[:default]).to eq('gpt-4.1-mini')
expect(config[:default]).to eq('gpt-5.6-luna')
expect(config[:reasoning_effort]).to eq('low')
expect(config[:models].pluck(:id)).to include('gpt-5.6-luna', 'gpt-5.6-terra')
expect(config[:models].first).to include(
id: 'gpt-4.1-mini',
display_name: 'GPT-4.1 Mini',
+2 -2
View File
@@ -393,10 +393,10 @@ RSpec.describe Account do
end
end
it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
it 'returns the Captain V2 default for assistant when Captain V2 is enabled' do
account.enable_features!('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.reload.captain_models).to be_nil
end
end
@@ -96,7 +96,14 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 36.minutes)).to be_expired
expect(
described_class.built_in_filter_counts_state(
account_id: account_id,
user_id: user_id,
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL +
Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second
)
).to be_expired
Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id))
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing
@@ -202,8 +209,18 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
)
snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
expect(described_class.refresh_due?(snapshot, now: built_at + 10.seconds)).to be(false)
expect(described_class.refresh_due?(snapshot, now: built_at + 31.seconds)).to be(true)
expect(
described_class.refresh_due?(
snapshot,
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
)
).to be(false)
expect(
described_class.refresh_due?(
snapshot,
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
)
).to be(true)
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true)
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false)
@@ -48,10 +48,22 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
create(:mention, account: account, conversation: second_mention, user: agent)
store.bump_conversation_version!(account.id)
expect(described_class.new(account: account, user: agent, now: now + 10.seconds).perform[:mentions_count]).to eq(1)
expect(
described_class.new(
account: account,
user: agent,
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
).perform[:mentions_count]
).to eq(1)
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
expect(described_class.new(account: account, user: agent, now: now + 31.seconds).perform[:mentions_count]).to eq(2)
expect(
described_class.new(
account: account,
user: agent,
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
).perform[:mentions_count]
).to eq(2)
end
it 'returns stale built-in counts when a refresh build hits a database error' do
@@ -62,7 +74,11 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
store.bump_conversation_version!(account.id)
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
failing_counter = described_class.new(account: account, user: agent, now: now + 31.seconds)
failing_counter = described_class.new(
account: account,
user: agent,
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
)
allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout'))
expect(failing_counter.perform[:mentions_count]).to eq(1)