Compare commits

...
58 Commits
Author SHA1 Message Date
Aakash BakhleandGitHub 6343cd1631 Merge branch 'develop' into feature/cw-6123 2026-01-06 14:23:50 +05:30
3e5b2979eb feat: Add support for sending CSAT surveys via templates (Whatsapp Cloud) (#12787)
This PR enables sending CSAT surveys on WhatsApp using approved WhatsApp
message templates, ensuring survey delivery even after the 24-hour
session window.

The system now automatically creates, updates, and monitors WhatsApp
CSAT templates without manual intervention.

<img width="1664" height="1792" alt="approved"
src="https://github.com/user-attachments/assets/c6efd61e-1d01-4738-abb6-0afc0dace975"
/>

#### Why this change

Previously, WhatsApp CSAT messages failed outside the 24-hour customer
window.

With this update:

- CSAT surveys are delivered reliably using WhatsApp templates
- Template creation happens automatically in the background
- Users can modify survey content and recreate templates easily
- Clear UI states show template approval status

#### Screens & States

<details>
<summary>Default — No template configured yet</summary>
<img width="1662" height="1788" alt="default"
src="https://github.com/user-attachments/assets/ed26d71b-cf7c-4a26-a2af-da88772c847c"
/>
</details>
<details>
<summary>Pending — Template submitted, awaiting Meta approval</summary>
<img width="1658" height="1816" alt="pending"
src="https://github.com/user-attachments/assets/923b789b-d91b-4364-905d-e56a2b65331a"
/>
</details>
<details>
<summary>Approved — Survey will be sent when conversation
resolves</summary>
<img width="1664" height="1792" alt="approved"
src="https://github.com/user-attachments/assets/c6efd61e-1d01-4738-abb6-0afc0dace975"
/>
</details>
<details>
<summary>Rejected — Template rejected by Meta</summary>
<img width="1672" height="1776" alt="rejected"
src="https://github.com/user-attachments/assets/f69a9b0e-be27-4e67-a993-7b8149502c4f"
/>
</details>
<details>
<summary>Not Found — Template missing in Meta Platform</summary>
<img width="1660" height="1784" alt="not-exist"
src="https://github.com/user-attachments/assets/a2a4b4f7-b01a-4424-8fcb-3ed84256e057"
/>
</details>
<details>
<summary>Edit Template — Delete & recreate template on change</summary>
<img width="2342" height="1778" alt="edit-survey"
src="https://github.com/user-attachments/assets/0f999285-0341-4226-84e9-31f0c6446924"
/>
</details>

#### Test Cases


**1. First-time CSAT setup on WhatsApp inbox**

- Enable CSAT
- Enter message + button text
- Save
- Expected: Template created automatically, UI shows pending state

**2. CSAT toggle without changing text**

- Existing approved template
- Toggle CSAT OFF → ON (no text change)
- Expected: No confirmation alert, no template recreation

**3. Editing only survey rules**

- Modify labels or rule conditions only
- Expected: No confirmation alert, template remains unchanged

**4. Template text change**

- Change survey message or button text
- Save
- Expected:
    - Confirmation dialog shown
    - On confirm → previous template deleted, new one created
    - On cancel → revert to previous values

**5. Language change**

- Change template language (e.g., en → es)
- Expected: Confirmation dialog + new template on confirm

 **6. Sending survey**

- Template approved → always send template
- Template pending → send free-form within 24 hours only
- Template rejected/missing → fallback to free-form (if within window)
- Outside 24 hours & no approved template → activity log only

**7. Non-WhatsApp inbox**

- Enable CSAT for email/web inbox
- Expected: No template logic triggered


Fixes
https://linear.app/chatwoot/issue/CW-6188/support-for-sending-csat-surveys-via-approved-whatsapp

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-01-06 11:46:00 +04:00
Muhsin KelothandGitHub bd698cb12c feat: Add call-to-action template support for Twilio (#13179)
Fixes
https://linear.app/chatwoot/issue/CW-6228/add-call-to-action-template-support-for-twilio-whatsapp-templates

Adds support for Twilio WhatsApp call-to-action templates, enabling
customers to use URL button templates with variable inputs.

<img width="2982" height="1388" alt="CleanShot 2026-01-05 at 16 25
55@2x"
src="https://github.com/user-attachments/assets/7cf332f5-3f3e-4ffb-a461-71c60a0156c8"
/>
2026-01-06 10:38:36 +04:00
PranavandGitHub 79381a4c5f fix: Add code_block method to WhatsApp and Instagram markdown renderers (#13166)
Problem: SystemStackError: stack level too deep occurred when rendering
messages with indented content (4+ spaces) for WhatsApp, Instagram, and
Facebook channels.

Root Cause: CommonMarker::Renderer#code_block contains a self-recursive
placeholder that must be overridden:
```
  def code_block(node)
    code_block(node)  # calls itself infinitely
  end
```

WhatsAppRenderer and InstagramRenderer were missing this override,
causing infinite recursion when markdown with 4-space indentation
(interpreted as code blocks) was rendered.

Fix: Added code_block method to both renderers that outputs the node
content as plain text:
```
  def code_block(node)
    out(node.string_content)
  end
 ```

Fix https://linear.app/chatwoot/issue/CW-6217/systemstackerror-stack-level-too-deep-systemstackerror
2025-12-30 11:25:54 -08:00
Shivam Mishra 88666b887e Merge branch 'feature/cw-6123' of github.com:chatwoot/chatwoot into feature/cw-6123 2025-12-23 19:48:24 +05:30
Shivam Mishra ae3687364d feat: abstract models 2025-12-23 19:38:01 +05:30
Shivam Mishra 4eed216560 test: update test for removed gpt-5 model 2025-12-23 19:33:38 +05:30
Shivam Mishra 2b14cd1268 feat: add captain featurable concern 2025-12-23 19:22:21 +05:30
aakashb95 9fc86b13cb fix: wrongly classified assistant event type 2025-12-23 17:54:44 +05:30
aakashb95 871ff9e1eb fix: captain logo in sidebar under settings 2025-12-23 16:29:05 +05:30
aakashb95 a64ce2f209 fix: spec 2025-12-23 16:18:18 +05:30
Shivam Mishra aa3b406ea5 style: installation config yml 2025-12-23 15:32:00 +05:30
Shivam Mishra def8a4bf99 refactor: use pinia store for captain config 2025-12-23 15:28:50 +05:30
Shivam Mishra fc8b2d6c72 feat: remove gpt-5 2025-12-23 15:27:14 +05:30
Shivam Mishra 2b0320ca7a chore: fix i18n grammar 2025-12-23 15:07:59 +05:30
Shivam Mishra 1b00a60836 feat: update icon and colors for model dropdown 2025-12-23 15:07:25 +05:30
Shivam Mishra ac64840ad7 feat: add model selector for label suggestion 2025-12-23 14:58:25 +05:30
Shivam Mishra 529f22954e feat: add gemini 2025-12-23 14:35:15 +05:30
Shivam Mishra c711ffb85a feat: add anthropic models 2025-12-23 14:24:04 +05:30
Shivam Mishra 8f1f9cb426 feat: add preferred badge 2025-12-23 13:59:08 +05:30
Shivam Mishra 6dc9e2a63b feat: add provider logos 2025-12-23 13:42:40 +05:30
Shivam Mishra 9ab5a27367 chore: format llm.yml 2025-12-23 13:36:22 +05:30
Shivam Mishra 6257641837 feat: update icons 2025-12-23 13:35:32 +05:30
Shivam Mishra d5a8673c82 feat: add captain paywall 2025-12-23 13:30:53 +05:30
Shivam MishraandGitHub 8a54cf41bd Merge branch 'develop' into feature/cw-6123 2025-12-23 13:22:50 +05:30
d57170712d fix: increase the alfred connection pool size to 10 (#13138)
## Description
Increased the alfred pool size to 10, so that each worker has enough
redis connection thread pool to work

## Type of change

Please delete options that are not relevant.

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


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces configurability for the Redis pool used by `alfred`.
> 
> - `$alfred` `ConnectionPool` size now reads from
`ENV['REDIS_ALFRED_SIZE']` with a default of `5`
> - Adds `REDIS_ALFRED_SIZE=10` to `.env.example`
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
96cdff8c0ea40f82a57d70be053780e87384ed47. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: tanmay <tanmay-chatwoot@tanmays-MacBook-Pro.local>
2025-12-23 13:59:20 +07:00
aakashb95 7fd7704fea Merge branch 'develop' into feature/cw-6123 2025-12-23 10:28:14 +05:30
Sojan Jose e7be8c14bd Merge branch 'release/4.9.1' into develop 2025-12-22 18:56:15 -08:00
Shivam Mishra ad1fc1490a feat: update copy 2025-12-22 18:15:31 +05:30
Shivam Mishra da4d41f615 refactor: use standard dropdown 2025-12-22 18:05:48 +05:30
Aakash BakhleandGitHub 0cf89a1dda Merge branch 'develop' into feature/cw-6123 2025-12-22 14:00:45 +05:30
aakashb95 566066b0d3 add back gpt-5.2 and 5.1 2025-12-19 14:22:03 +05:30
aakashb95 5b5e6a852f Revert "better defaults and rubyllm throws error of model not found for gpt-5.1 and gpt-5.2"
This reverts commit d75e1393d8.
2025-12-19 14:13:41 +05:30
aakashb95 350fdd0fa0 fix: upsert instead of replace captain_models obj inside settings 2025-12-19 13:58:45 +05:30
aakashb95 2161690ec4 defaults are read from llm.yml 2025-12-19 13:55:48 +05:30
aakashb95 379bfa1b89 fix: label suggestion enterprise feature 2025-12-19 13:55:29 +05:30
aakashb95 d75e1393d8 better defaults and rubyllm throws error of model not found for gpt-5.1 and gpt-5.2 2025-12-19 13:54:56 +05:30
aakashb95 4ac6c19c19 ui and be for toggles 2025-12-18 16:51:29 +05:30
aakashb95 0a0f96ea0a add audio transcription and help center indexing config 2025-12-18 16:47:52 +05:30
aakashb95 4f332a8a74 model spec fix 2025-12-18 15:45:48 +05:30
aakashb95 aedd33a76f lint fix 2025-12-18 15:16:06 +05:30
Aakash BakhleandGitHub 80fe8ca493 Merge branch 'develop' into feature/cw-6123 2025-12-18 15:12:47 +05:30
aakashb95 f084dc77c8 hide sidebar captain setting 2025-12-18 14:22:23 +05:30
aakashb95 e7507d7627 fix captain icon 2025-12-18 13:11:05 +05:30
aakashb95 26db79584b fix dropdowns 2025-12-18 13:09:34 +05:30
aakashb95 d95d63d59d initial commit 2025-12-18 12:54:59 +05:30
aakashb95 d421da3537 remove 4o and 4o mini 2025-12-17 17:48:42 +05:30
b31b220885 Update lib/llm/config_service.rb
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-17 17:26:08 +05:30
5f689518af Update lib/llm/config_service.rb
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-17 17:25:55 +05:30
c630a609aa Update lib/llm/config_service.rb
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-17 17:25:46 +05:30
aakashb95 5d1713485c fix rubocop violation 2025-12-17 15:33:12 +05:30
aakashb95 1dde9329dd remove error handling if yaml load fails 2025-12-17 15:19:43 +05:30
aakashb95 bf76fd3769 add spec 2025-12-17 15:19:31 +05:30
aakashb95 a9a113bace add config routes 2025-12-17 15:19:24 +05:30
aakashb95 65e3f8231f captain preference in account settings 2025-12-17 15:18:26 +05:30
aakashb95 f2d3a6ee4f load llm.yml 2025-12-17 14:48:46 +05:30
aakashb95 b67dbf3761 add gpt-5-nano 2025-12-15 17:14:14 +05:30
aakashb95 a49521c9d6 add model config for global captain settings 2025-12-15 17:09:28 +05:30
63 changed files with 2403 additions and 135 deletions
+2
View File
@@ -274,3 +274,5 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
@@ -0,0 +1,27 @@
class Api::V1::Accounts::Captain::ConfigController < Api::V1::Accounts::BaseController
before_action :current_account
def show
render json: {
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
private
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
)
end
end
end
+12 -2
View File
@@ -92,8 +92,18 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
permitted = params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
if params[:captain_models].present?
existing_models = @account.captain_models || {}
permitted[:captain_models] = existing_models.merge(params[:captain_models].to_unsafe_h)
end
if params[:captain_features].present?
existing_features = @account.captain_features || {}
permitted[:captain_features] = existing_features.merge(params[:captain_features].to_unsafe_h)
end
permitted
end
def check_signup_enabled
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainConfig extends ApiClient {
constructor() {
super('captain/config', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
}
export default new CaptainConfig();
+10
View File
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
createCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template`, {
template,
});
}
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
}
export default new Inboxes();
@@ -0,0 +1,28 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
buttonText: {
type: String,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
</div>
</div>
</template>
@@ -493,6 +493,13 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
// TODO: Hide before merging to develop until we have a way to manage captain settings
{
name: 'Settings Captain',
label: t('SIDEBAR.CAPTAIN_AI'),
icon: 'i-woot-captain',
to: accountScopedRoute('captain_settings_index'),
},
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
@@ -859,6 +859,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
display: none !important;
}
> .ProseMirror {
@@ -41,6 +41,9 @@ const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
@@ -140,6 +140,11 @@ export const FORMATTING = {
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
'Context::Plain': {
marks: [],
nodes: [],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
export function getHelpUrlForFeature(featureName) {
@@ -28,6 +28,7 @@
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text"
}
},
@@ -7,6 +7,7 @@
},
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
"PREFERRED": "Preferred"
}
}
@@ -808,6 +808,35 @@
"LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form"
},
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -378,7 +378,56 @@
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs",
"SECURITY": "Security"
"SECURITY": "Security",
"CAPTAIN_AI": "Captain"
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
"LOADING": "Loading Captain configuration...",
"LINK_TEXT": "Learn more about Captain Credits",
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
"MODEL_CONFIG": {
"TITLE": "Model Configuration",
"DESCRIPTION": "Select AI models for different features.",
"SELECT_MODEL": "Select model",
"CREDITS_PER_MESSAGE": "{credits} credit/message",
"COMING_SOON": "Coming soon",
"EDITOR": {
"TITLE": "Editor Features",
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
},
"ASSISTANT": {
"TITLE": "Assistant",
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
},
"COPILOT": {
"TITLE": "Co-pilot",
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
}
},
"FEATURES": {
"TITLE": "Features",
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
"DESCRIPTION": "Enable AI to search and reference your help center articles for accurate, context-aware customer responses."
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
"MODEL_TITLE": "Label Suggestion Model",
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
}
},
"API": {
"SUCCESS": "Captain settings updated successfully.",
"ERROR": "Failed to update Captain settings. Please try again."
}
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -0,0 +1,155 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCaptainConfigStore } from 'dashboard/store/captain/config';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SectionLayout from '../account/components/SectionLayout.vue';
import ModelSelector from './components/ModelSelector.vue';
import FeatureToggle from './components/FeatureToggle.vue';
import CaptainPaywall from 'next/captain/pageComponents/Paywall.vue';
const { t } = useI18n();
const store = useStore();
const { captainEnabled } = useCaptain();
const { isEnterprise } = useConfig();
const captainConfigStore = useCaptainConfigStore();
const { uiFlags, features } = storeToRefs(captainConfigStore);
const isLoading = computed(() => uiFlags.value.isFetching);
const modelFeatures = computed(() => [
{
key: 'editor',
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.TITLE'),
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.DESCRIPTION'),
},
{
key: 'assistant',
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.TITLE'),
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.DESCRIPTION'),
},
{
key: 'copilot',
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.TITLE'),
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.DESCRIPTION'),
},
]);
const featureToggles = computed(() => [
{
key: 'audio_transcription',
enterprise: true,
},
{
key: 'help_center_search',
enterprise: true,
},
{
key: 'label_suggestion',
enterprise: true,
},
]);
const hasFeatureToggle = feature => {
const featureExists = features.value[feature.key] !== undefined;
const isEnterpriseAllowed = !feature.enterprise || isEnterprise;
return featureExists && isEnterpriseAllowed;
};
async function handleFeatureToggle({ feature, enabled }) {
try {
await store.dispatch('accounts/update', {
captain_features: { [feature]: enabled },
});
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
} catch (error) {
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
// Refetch to revert the toggle state
captainConfigStore.fetch();
}
}
async function handleModelChange({ feature, model }) {
try {
await store.dispatch('accounts/update', {
captain_models: { [feature]: model },
});
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
} catch (error) {
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
// Refetch to revert the model selection
captainConfigStore.fetch();
}
}
onMounted(() => {
captainConfigStore.fetch();
});
</script>
<template>
<SettingsLayout
:is-loading="isLoading"
:no-records-message="t('CAPTAIN_SETTINGS.NOT_ENABLED')"
:loading-message="t('CAPTAIN_SETTINGS.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="t('CAPTAIN_SETTINGS.TITLE')"
:description="t('CAPTAIN_SETTINGS.DESCRIPTION')"
:link-text="t('CAPTAIN_SETTINGS.LINK_TEXT')"
icon-name="captain"
feature-name="captain_billing"
/>
</template>
<template #body>
<div v-if="captainEnabled" class="flex flex-col gap-1">
<!-- Model Configuration Section -->
<SectionLayout
:title="t('CAPTAIN_SETTINGS.MODEL_CONFIG.TITLE')"
:description="t('CAPTAIN_SETTINGS.MODEL_CONFIG.DESCRIPTION')"
>
<div class="grid gap-4">
<ModelSelector
v-for="feature in modelFeatures"
:key="feature.key"
:feature-key="feature.key"
:title="feature.title"
:description="feature.description"
@change="handleModelChange"
/>
</div>
</SectionLayout>
<!-- Features Section -->
<SectionLayout
:title="t('CAPTAIN_SETTINGS.FEATURES.TITLE')"
:description="t('CAPTAIN_SETTINGS.FEATURES.DESCRIPTION')"
with-border
>
<div class="grid gap-4">
<FeatureToggle
v-for="feature in featureToggles"
v-show="hasFeatureToggle(feature)"
:key="feature.key"
:feature-key="feature.key"
@change="handleFeatureToggle"
@model-change="handleModelChange"
/>
</div>
</SectionLayout>
</div>
<div v-else>
<CaptainPaywall />
</div>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,33 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/captain'),
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
},
component: SettingsWrapper,
props: {
headerTitle: 'CAPTAIN_SETTINGS.TITLE',
icon: 'i-lucide-bot',
showNewButton: false,
},
children: [
{
path: '',
name: 'captain_settings_index',
component: Index,
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
},
},
],
},
],
};
@@ -0,0 +1,153 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useCaptainConfigStore } from 'dashboard/store/captain/config';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import ModelDropdown from './ModelDropdown.vue';
const props = defineProps({
featureKey: {
type: String,
required: true,
},
});
const emit = defineEmits(['change', 'modelChange']);
const { t } = useI18n();
const captainConfigStore = useCaptainConfigStore();
const { features } = storeToRefs(captainConfigStore);
const availableModels = computed(() =>
captainConfigStore.getModelsForFeature(props.featureKey)
);
const isEnabled = ref(false);
const featureConfig = computed(() => features.value[props.featureKey]);
const hasMultipleModels = computed(() => {
return availableModels.value && availableModels.value.length > 1;
});
const showModelSelector = computed(() => {
return isEnabled.value && hasMultipleModels.value;
});
const title = computed(() => {
const key = `CAPTAIN_SETTINGS.FEATURES.${props.featureKey.toUpperCase()}.TITLE`;
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(key);
});
const description = computed(() => {
const key = `CAPTAIN_SETTINGS.FEATURES.${props.featureKey.toUpperCase()}.DESCRIPTION`;
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(key);
});
const modelTitle = computed(() => {
const key = `CAPTAIN_SETTINGS.FEATURES.${props.featureKey.toUpperCase()}.MODEL_TITLE`;
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(key);
});
const modelDescription = computed(() => {
const key = `CAPTAIN_SETTINGS.FEATURES.${props.featureKey.toUpperCase()}.MODEL_DESCRIPTION`;
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(key);
});
watch(
featureConfig,
newConfig => {
if (newConfig !== undefined) {
isEnabled.value = !!newConfig.enabled;
}
},
{ immediate: true }
);
const toggleFeature = () => {
emit('change', { feature: props.featureKey, enabled: isEnabled.value });
};
const handleModelChange = ({ feature, model }) => {
emit('modelChange', { feature, model });
};
</script>
<template>
<div
class="p-4 rounded-xl border border-n-weak bg-n-solid-1"
:class="
showModelSelector
? 'flex flex-col gap-3'
: 'flex items-center justify-between gap-4'
"
>
<div class="flex items-center justify-between gap-4 flex-1">
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-n-slate-12">{{ title }}</h4>
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
</div>
<div class="flex-shrink-0">
<Switch v-model="isEnabled" @change="toggleFeature" />
</div>
</div>
<div v-if="showModelSelector" class="flex gap-2 ps-8 model-selector-item">
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-n-slate-12">{{ modelTitle }}</h4>
<p class="text-sm text-n-slate-11 mt-0.5">{{ modelDescription }}</p>
</div>
<div class="flex justify-end">
<ModelDropdown :feature-key="featureKey" @change="handleModelChange" />
</div>
</div>
</div>
</template>
<style scoped>
.model-selector-item {
position: relative;
}
.model-selector-item::before {
content: '';
position: absolute;
width: 0.125rem;
height: 50%;
top: 0;
left: 0.75rem;
@apply border-n-weak bg-n-weak;
}
.model-selector-item::after {
content: '';
position: absolute;
width: 10px;
height: 12px;
top: calc(50% - 6px);
left: 0.75rem;
border-bottom-width: 0.125rem;
border-left-width: 0.125rem;
border-right-width: 0px;
border-top-width: 0px;
border-radius: 0 0 0 4px;
@apply border-n-weak;
}
#app[dir='rtl'] .model-selector-item::before {
left: auto;
right: 0.75rem;
}
#app[dir='rtl'] .model-selector-item::after {
left: auto;
right: 0.75rem;
border-left-width: 0px;
border-right-width: 0.125rem;
border-radius: 0 0 4px 0;
}
</style>
@@ -0,0 +1,151 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useCaptainConfigStore } from 'dashboard/store/captain/config';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownBody from 'dashboard/components-next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'dashboard/components-next/dropdown-menu/base/DropdownItem.vue';
import { provideDropdownContext } from 'dashboard/components-next/dropdown-menu/base/provider.js';
const props = defineProps({
featureKey: {
type: String,
required: true,
},
});
const emit = defineEmits(['change']);
const PROVIDER_ICONS = {
openai: 'i-ri-openai-fill',
anthropic: 'i-ri-anthropic-line',
mistral: 'i-logos-mistral-icon',
gemini: 'i-woot-gemini',
};
const iconForModel = model => {
return PROVIDER_ICONS[model.provider];
};
const { t } = useI18n();
const captainConfigStore = useCaptainConfigStore();
const isOpen = ref(false);
const availableModels = computed(() =>
captainConfigStore.getModelsForFeature(props.featureKey)
);
const recommendedModelId = computed(() =>
captainConfigStore.getDefaultModelForFeature(props.featureKey)
);
const selectedModel = computed(() =>
captainConfigStore.getSelectedModelForFeature(props.featureKey)
);
const selectedModelId = ref(null);
watch(
selectedModel,
newSelected => {
if (newSelected) {
selectedModelId.value = newSelected;
}
},
{ immediate: true }
);
const selectedModelDetails = computed(() => {
if (!selectedModelId.value) return null;
return (
availableModels.value.find(m => m.id === selectedModelId.value) || null
);
});
const getCreditLabel = model => {
const multiplier = model.credit_multiplier || 1;
return t('CAPTAIN_SETTINGS.MODEL_CONFIG.CREDITS_PER_MESSAGE', {
credits: multiplier,
});
};
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const closeDropdown = () => {
isOpen.value = false;
};
provideDropdownContext({
isOpen,
toggle: () => toggleDropdown(),
closeMenu: closeDropdown,
});
const selectModel = model => {
selectedModelId.value = model.id;
emit('change', { feature: props.featureKey, model: model.id });
closeDropdown();
};
</script>
<template>
<div v-on-clickaway="closeDropdown" class="relative flex-shrink-0">
<button
type="button"
class="flex items-center gap-2 px-3 py-2 text-sm border rounded-lg border-n-weak dark:bg-n-solid-2 dark:hover:bg-n-solid-3 bg-n-alpha-2 hover:bg-n-alpha-1 min-w-[180px] justify-between"
@click="toggleDropdown"
>
<span v-if="selectedModelDetails" class="text-n-slate-12">
{{ selectedModelDetails.display_name }}
</span>
<span v-else class="text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.SELECT_MODEL') }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="size-4 text-n-slate-11 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<DropdownBody
v-if="isOpen"
class="absolute right-0 top-full mt-1 min-w-64 z-50 max-h-96 [&>ul]:max-h-96 [&>ul]:overflow-y-scroll"
>
<DropdownItem
v-for="model in availableModels"
:key="model.id"
:click="() => selectModel(model)"
class="rounded-lg dark:hover:bg-n-solid-3 hover:bg-n-alpha-1"
:class="{
'dark:bg-n-solid-3 bg-n-alpha-1': selectedModelId === model.id,
'pointer-events-none opacity-60': model.coming_soon,
}"
>
<div class="flex gap-2 w-full">
<Icon :icon="iconForModel(model)" class="size-4 flex-shrink-0" />
<div class="flex flex-col w-full text-left gap-1">
<div
class="text-sm w-full font-medium leading-none text-n-slate-12 flex items-baseline justify-between"
>
{{ model.display_name }}
<span
v-if="model.id === recommendedModelId"
class="text-[10px] uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
>
{{ t('GENERAL.PREFERRED') }}
</span>
</div>
<span v-if="model.coming_soon" class="text-xs text-n-slate-11">
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.COMING_SOON') }}
</span>
<span v-else class="text-xs text-n-slate-11">
{{ getCreditLabel(model) }}
</span>
</div>
</div>
</DropdownItem>
</DropdownBody>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
import ModelDropdown from './ModelDropdown.vue';
defineProps({
featureKey: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
});
const emit = defineEmits(['change']);
const handleModelChange = ({ feature, model }) => {
emit('change', { feature, model });
};
</script>
<template>
<div
class="flex items-center justify-between gap-4 p-4 rounded-xl border border-n-weak bg-n-solid-1"
>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-n-slate-12">{{ title }}</h4>
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
</div>
<ModelDropdown :feature-key="featureKey" @change="handleModelChange" />
</div>
</template>
@@ -3,15 +3,22 @@ import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'next/switch/Switch.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
const props = defineProps({
inbox: { type: Object, required: true },
@@ -21,6 +28,10 @@ const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
props.inbox?.id
);
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -29,7 +40,19 @@ const state = reactive({
csatSurveyEnabled: false,
displayType: 'emoji',
message: '',
templateButtonText: 'Please rate us',
surveyRuleOperator: 'contains',
templateLanguage: '',
});
const templateStatus = ref(null);
const templateLoading = ref(false);
const confirmDialog = ref(null);
const originalTemplateValues = ref({
message: '',
templateButtonText: '',
templateLanguage: '',
});
const filterTypes = [
@@ -51,6 +74,59 @@ const labelOptions = computed(() =>
: []
);
const languageOptions = computed(() =>
languages.map(({ name, id }) => ({ label: `${name} (${id})`, value: id }))
);
const messagePreviewData = computed(() => ({
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
}));
const shouldShowTemplateStatus = computed(
() => templateStatus.value && !templateLoading.value
);
const templateApprovalStatus = computed(() => {
const statusMap = {
APPROVED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'),
icon: 'i-lucide-circle-check',
color: 'text-n-teal-11',
},
PENDING: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'),
icon: 'i-lucide-clock',
color: 'text-n-amber-11',
},
REJECTED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'),
icon: 'i-lucide-circle-x',
color: 'text-n-ruby-10',
},
};
// Handle template not found case
if (templateStatus.value?.error === 'TEMPLATE_NOT_FOUND') {
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.NOT_FOUND'),
icon: 'i-lucide-alert-triangle',
color: 'text-n-ruby-10',
};
}
// Handle existing template with status
if (templateStatus.value?.template_exists && templateStatus.value.status) {
return statusMap[templateStatus.value.status] || statusMap.PENDING;
}
// Default case - no template exists
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
icon: 'i-lucide-stamp',
color: 'text-n-slate-11',
};
});
const initializeState = () => {
if (!props.inbox) return;
@@ -63,21 +139,63 @@ const initializeState = () => {
const {
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
message = '',
button_text: buttonText = 'Please rate us',
language = 'en',
survey_rules: surveyRules = {},
} = csat_config;
state.displayType = displayType;
state.message = message;
state.templateButtonText = buttonText;
state.templateLanguage = language;
state.surveyRuleOperator = surveyRules.operator || 'contains';
selectedLabelValues.value = Array.isArray(surveyRules.values)
? [...surveyRules.values]
: [];
// Store original template values for change detection
if (isWhatsAppChannel.value) {
originalTemplateValues.value = {
message: state.message,
templateButtonText: state.templateButtonText,
templateLanguage: state.templateLanguage,
};
}
};
const checkTemplateStatus = async () => {
if (!isWhatsAppChannel.value) return;
try {
templateLoading.value = true;
const response = await store.dispatch('inboxes/getCSATTemplateStatus', {
inboxId: props.inbox.id,
});
// Handle case where template doesn't exist
if (!response.template_exists && response.error === 'Template not found') {
templateStatus.value = {
template_exists: false,
error: 'TEMPLATE_NOT_FOUND',
};
} else {
templateStatus.value = response;
}
} catch (error) {
templateStatus.value = {
template_exists: false,
error: 'API_ERROR',
};
} finally {
templateLoading.value = false;
}
};
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
if (isWhatsAppChannel.value) checkTemplateStatus();
});
watch(() => props.inbox, initializeState, { immediate: true });
@@ -105,6 +223,49 @@ const removeLabel = label => {
}
};
// Check if template-related fields have changed
const hasTemplateChanges = () => {
if (!isWhatsAppChannel.value) return false;
const original = originalTemplateValues.value;
return (
original.message !== state.message ||
original.templateButtonText !== state.templateButtonText ||
original.templateLanguage !== state.templateLanguage
);
};
// Check if there's an existing template
const hasExistingTemplate = () => {
const { template_exists, error } = templateStatus.value || {};
return template_exists && !error;
};
// Check if we should create a template
const shouldCreateTemplate = () => {
// Create template if no existing template
if (!hasExistingTemplate()) {
return true;
}
// Create template if there are changes to template fields
return hasTemplateChanges();
};
// Build template config for saving
const buildTemplateConfig = () => {
if (!hasExistingTemplate()) return null;
const { template_name, template_id, template, status } =
templateStatus.value || {};
return {
name: template_name,
template_id,
language: template?.language || state.templateLanguage,
status,
};
};
const updateInbox = async attributes => {
const payload = {
id: props.inbox.id,
@@ -115,31 +276,103 @@ const updateInbox = async attributes => {
return store.dispatch('inboxes/updateInbox', payload);
};
const saveSettings = async () => {
const createTemplate = async () => {
if (!isWhatsAppChannel.value) return null;
const response = await store.dispatch('inboxes/createCSATTemplate', {
inboxId: props.inbox.id,
template: {
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
},
});
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE'));
return response.template;
};
const performSave = async () => {
try {
isUpdating.value = true;
let newTemplateData = null;
// For WhatsApp channels, create template first if needed
if (
isWhatsAppChannel.value &&
state.csatSurveyEnabled &&
shouldCreateTemplate()
) {
try {
newTemplateData = await createTemplate();
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
useAlert(errorMessage);
return;
}
}
const csatConfig = {
display_type: state.displayType,
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
survey_rules: {
operator: state.surveyRuleOperator,
values: selectedLabelValues.value,
},
};
// Use new template data if created, otherwise preserve existing template information
if (newTemplateData) {
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
} else {
const templateConfig = buildTemplateConfig();
if (templateConfig) {
csatConfig.template = templateConfig;
}
}
await updateInbox({
csat_survey_enabled: state.csatSurveyEnabled,
csat_config: csatConfig,
});
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
checkTemplateStatus();
} catch (error) {
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
} finally {
isUpdating.value = false;
}
};
const saveSettings = async () => {
// Check if we need to show confirmation dialog for WhatsApp template changes
if (
isWhatsAppChannel.value &&
state.csatSurveyEnabled &&
hasExistingTemplate() &&
hasTemplateChanges()
) {
confirmDialog.value?.open();
return;
}
await performSave();
};
const handleConfirmTemplateUpdate = async () => {
// We will delete the template before creating the template
await performSave();
};
</script>
<template>
@@ -155,7 +388,9 @@ const saveSettings = async () => {
</template>
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
v-if="!isWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
@@ -165,14 +400,97 @@ const saveSettings = async () => {
/>
</WithLabel>
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
<template v-if="isWhatsAppChannel">
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
<div class="flex flex-col gap-3 basis-3/5">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
channel-type="Context::Plain"
class="w-full"
/>
</WithLabel>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
class="w-full"
/>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
<div
v-if="shouldShowTemplateStatus"
class="flex gap-2 items-center mt-4"
>
<Icon
:icon="templateApprovalStatus.icon"
:class="templateApprovalStatus.color"
class="size-4"
/>
<span
:class="templateApprovalStatus.color"
class="text-sm font-medium"
>
{{ templateApprovalStatus.text }}
</span>
</div>
</div>
<div
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
>
<p
class="inline-flex items-center text-sm font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
<Icon
v-tooltip.top-end="
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
"
icon="i-lucide-info"
class="flex-shrink-0 mx-1 size-4"
/>
</p>
<CSATTemplate
:message="messagePreviewData"
:button-text="state.templateButtonText"
class="pt-12"
/>
</div>
</div>
</template>
<!-- Non-WhatsApp channels layout -->
<template v-else>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
</template>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
@@ -180,7 +498,7 @@ const saveSettings = async () => {
>
<div class="mb-4">
<span
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
<FilterSelect
@@ -217,7 +535,11 @@ const saveSettings = async () => {
</div>
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
{{
isWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
</p>
<div>
<NextButton
@@ -229,5 +551,11 @@ const saveSettings = async () => {
</div>
</div>
</SectionLayout>
<!-- Template Update Confirmation Dialog -->
<ConfirmTemplateUpdateDialog
ref="confirmDialog"
@confirm="handleConfirmTemplateUpdate"
/>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
dialogRef.value?.close();
};
defineExpose({
dialogRef,
open: () => dialogRef.value?.open(),
close: () => dialogRef.value?.close(),
});
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.TITLE')"
:description="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.DESCRIPTION')"
:confirm-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CONFIRM')"
:cancel-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CANCEL')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
import captain from './captain/captain.routes';
export default {
routes: [
@@ -63,5 +64,6 @@ export default {
...customRoles.routes,
...profile.routes,
...security.routes,
...captain.routes,
],
};
@@ -0,0 +1,64 @@
import { defineStore } from 'pinia';
import CaptainConfigAPI from 'dashboard/api/captain/config';
export const useCaptainConfigStore = defineStore('captainConfig', {
state: () => ({
providers: {},
models: {},
features: {},
uiFlags: {
isFetching: false,
},
}),
getters: {
getProviders: state => state.providers,
getModels: state => state.models,
getFeatures: state => state.features,
getUIFlags: state => state.uiFlags,
getModelsForFeature: state => featureKey => {
const feature = state.features[featureKey];
const models = feature?.models || [];
const providerOrder = { openai: 0, anthropic: 1, gemini: 2 };
return [...models].sort((a, b) => {
// Move coming_soon items to the end
if (a.coming_soon && !b.coming_soon) return 1;
if (!a.coming_soon && b.coming_soon) return -1;
// Sort by provider
const providerA = providerOrder[a.provider] ?? 999;
const providerB = providerOrder[b.provider] ?? 999;
if (providerA !== providerB) return providerA - providerB;
// Sort by credit_multiplier (highest first)
return (b.credit_multiplier || 0) - (a.credit_multiplier || 0);
});
},
getDefaultModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.default || null;
},
getSelectedModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.selected || feature?.default || null;
},
},
actions: {
async fetch() {
this.uiFlags.isFetching = true;
try {
const response = await CaptainConfigAPI.get();
this.providers = response.data.providers || {};
this.models = response.data.models || {};
this.features = response.data.features || {};
} catch (error) {
// Ignore error
} finally {
this.uiFlags.isFetching = false;
}
},
},
});
@@ -83,6 +83,14 @@ export const getters = {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
@@ -344,6 +352,14 @@ export const actions = {
throw new Error(error);
}
},
createCSATTemplate: async (_, { inboxId, template }) => {
const response = await InboxesAPI.createCSATTemplate(inboxId, template);
return response.data;
},
getCSATTemplateStatus: async (_, { inboxId }) => {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
};
export const mutations = {
@@ -164,4 +164,5 @@ export const TWILIO_CONTENT_TEMPLATE_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
CALL_TO_ACTION: 'call_to_action',
};
+27
View File
@@ -28,6 +28,7 @@ class Account < ApplicationRecord
include Reportable
include Featurable
include CacheKeys
include CaptainFeaturable
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
@@ -41,6 +42,30 @@ class Account < ApplicationRecord
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
},
'captain_models': {
'type': %w[object null],
'properties': {
'editor': { 'type': %w[string null] },
'assistant': { 'type': %w[string null] },
'copilot': { 'type': %w[string null] },
'label_suggestion': { 'type': %w[string null] },
'audio_transcription': { 'type': %w[string null] },
'help_center_search': { 'type': %w[string null] }
},
'additionalProperties': false
},
'captain_features': {
'type': %w[object null],
'properties': {
'editor': { 'type': %w[boolean null] },
'assistant': { 'type': %w[boolean null] },
'copilot': { 'type': %w[boolean null] },
'label_suggestion': { 'type': %w[boolean null] },
'audio_transcription': { 'type': %w[boolean null] },
'help_center_search': { 'type': %w[boolean null] }
},
'additionalProperties': false
}
},
'required': [],
@@ -59,7 +84,9 @@ class Account < ApplicationRecord
attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
store_accessor :settings, :captain_models, :captain_features
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
+62
View File
@@ -0,0 +1,62 @@
# frozen_string_literal: true
module CaptainFeaturable
extend ActiveSupport::Concern
included do
validate :validate_captain_models
# Dynamically define accessor methods for each captain feature
Llm::Models.feature_keys.each do |feature_key|
# Define enabled? methods (e.g., captain_editor_enabled?)
define_method("captain_#{feature_key}_enabled?") do
captain_features_with_defaults[feature_key]
end
# Define model accessor methods (e.g., captain_editor_model)
define_method("captain_#{feature_key}_model") do
captain_models_with_defaults[feature_key]
end
end
end
def captain_preferences
{
models: captain_models_with_defaults,
features: captain_features_with_defaults
}.with_indifferent_access
end
private
def captain_models_with_defaults
stored_models = captain_models || {}
Llm::Models.feature_keys.each_with_object({}) do |feature_key, result|
stored_value = stored_models[feature_key]
result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value)
stored_value
else
Llm::Models.default_model_for(feature_key)
end
end
end
def captain_features_with_defaults
stored_features = captain_features || {}
Llm::Models.feature_keys.index_with do |feature_key|
stored_features[feature_key] == true
end
end
def validate_captain_models
return if captain_models.blank?
captain_models.each do |feature_key, model_name|
next if model_name.blank?
next if Llm::Models.valid_model_for?(feature_key, model_name)
allowed_models = Llm::Models.models_for(feature_key)
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
end
end
end
+61 -1
View File
@@ -4,7 +4,9 @@ class CsatSurveyService
def perform
return unless should_send_csat_survey?
if within_messaging_window?
if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey
elsif within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
create_csat_not_sent_activity_message
@@ -35,6 +37,64 @@ class CsatSurveyService
conversation.can_reply?
end
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
def template_available_and_approved?
template_config = inbox.csat_config&.dig('template')
return false unless template_config
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
status_result = inbox.channel.provider_service.get_template_status(template_name)
status_result[:success] && status_result[:template][:status] == 'APPROVED'
rescue StandardError => e
Rails.logger.error "Error checking CSAT template status: #{e.message}"
false
end
def send_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
phone_number = conversation.contact_inbox.source_id
template_info = build_template_info(template_name, template_config)
message = build_csat_message
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
message.update!(source_id: message_id) if message_id.present?
rescue StandardError => e
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
def build_template_info(template_name, template_config)
{
name: template_name,
lang_code: template_config['language'] || 'en',
parameters: [
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: conversation.uuid }]
}
]
}
end
def build_csat_message
conversation.messages.build(
account: conversation.account,
inbox: inbox,
message_type: :outgoing,
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
content_type: :input_csat
)
end
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
activity_message_params = {
@@ -42,6 +42,10 @@ class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRendere
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
@@ -30,6 +30,10 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
@@ -23,8 +23,8 @@ class Twilio::TemplateProcessorService
def build_content_variables(template)
case template['template_type']
when 'text', 'quick_reply'
convert_text_template(template_params) # Text and quick reply templates use body variables
when 'text', 'quick_reply', 'call_to_action'
convert_text_template(template_params) # Text, quick reply and call-to-action templates use body variables
when 'media'
convert_media_template(template_params)
else
@@ -63,6 +63,8 @@ class Twilio::TemplateSyncService
'media'
elsif template_types.include?('twilio/quick-reply')
'quick_reply'
elsif template_types.include?('twilio/call-to-action')
'call_to_action'
elsif template_types.include?('twilio/catalog')
'catalog'
else
@@ -107,6 +109,8 @@ class Twilio::TemplateSyncService
template_types['twilio/media']['body']
elsif template_types['twilio/quick-reply']
template_types['twilio/quick-reply']['body']
elsif template_types['twilio/call-to-action']
template_types['twilio/call-to-action']['body']
elsif template_types['twilio/catalog']
template_types['twilio/catalog']['body']
else
@@ -25,3 +25,4 @@ json.name @account.name
json.support_email @account.support_email
json.status @account.status
json.cache_keys @account.cache_keys
json.captain_preferences @account.captain_preferences
+2 -1
View File
@@ -5,7 +5,8 @@
# Alfred
# Add here as you use it for more features
# Used for Round Robin, Conversation Emails & Online Presence
$alfred = ConnectionPool.new(size: 5, timeout: 1) do
alfred_size = ENV.fetch('REDIS_ALFRED_SIZE', 5)
$alfred = ConnectionPool.new(size: alfred_size, timeout: 1) do
redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
Redis::Namespace.new('alfred', redis: redis, warning: true)
end
+1 -1
View File
@@ -179,7 +179,7 @@
type: secret
- name: CAPTAIN_OPEN_AI_MODEL
display_title: 'OpenAI Model'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini'
locked: false
- name: CAPTAIN_OPEN_AI_ENDPOINT
display_title: 'OpenAI API Endpoint (optional)'
+118
View File
@@ -0,0 +1,118 @@
providers:
openai:
display_name: 'OpenAI'
anthropic:
display_name: 'Anthropic'
gemini:
display_name: 'Gemini'
models:
gpt-4.1:
provider: openai
display_name: 'GPT-4.1'
credit_multiplier: 0.5
gpt-4.1-mini:
provider: openai
display_name: 'GPT-4.1 Mini'
credit_multiplier: 0.3
gpt-4.1-nano:
provider: openai
display_name: 'GPT-4.1 Nano'
credit_multiplier: 0.1
gpt-5.1:
provider: openai
display_name: 'GPT-5.1'
credit_multiplier: 1.2
gpt-5-mini:
provider: openai
display_name: 'GPT-5 Mini'
credit_multiplier: 0.8
gpt-5-nano:
provider: openai
display_name: 'GPT-5 Nano'
credit_multiplier: 0.3
gpt-5.2:
provider: openai
display_name: 'GPT-5.2'
credit_multiplier: 1.5
claude-haiku-4.5:
provider: anthropic
display_name: 'Claude Haiku 4.5'
coming_soon: true
credit_multiplier: 1
claude-sonnet-4:
provider: anthropic
display_name: 'Claude Sonnet 4'
coming_soon: true
credit_multiplier: 2
gemini-3-flash:
provider: gemini
display_name: 'Gemini 3 Flash'
coming_soon: true
credit_multiplier: 0.5
gemini-3-pro:
provider: gemini
display_name: 'Gemini 3 Pro'
coming_soon: true
credit_multiplier: 1.5
whisper-1:
provider: openai
display_name: 'Whisper'
credit_multiplier: 0.2
text-embedding-3-small:
provider: openai
display_name: 'Text Embedding 3 Small'
credit_multiplier: 0.1
features:
editor:
models:
[
gpt-4.1-mini,
gpt-4.1-nano,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
claude-haiku-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1-mini
assistant:
models:
[
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
claude-haiku-4.5,
claude-sonnet-4,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-5.2
copilot:
models:
[
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
claude-haiku-4.5,
claude-sonnet-4,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-5.2
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-nano
audio_transcription:
models: [whisper-1]
default: whisper-1
help_center_search:
models: [text-embedding-3-small]
default: text-embedding-3-small
# add deprecation_map later
+1
View File
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
post :bulk_create, on: :collection
end
namespace :captain do
resource :config, only: [:show], controller: 'config'
resources :assistants do
member do
post :playground
@@ -20,7 +20,7 @@ module Captain::ChatHelper
private
def build_chat
llm_chat = chat(model: @model, temperature: temperature)
llm_chat = chat(model: model, temperature: temperature)
llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat)
@@ -74,7 +74,7 @@ module Captain::ChatHelper
account_id: resolved_account_id,
conversation_id: @conversation_id,
feature_name: feature_name,
model: @model,
model: model,
messages: chat ? chat.messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } : @messages,
temperature: temperature,
metadata: {
@@ -45,4 +45,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
def feature_name
'assistant'
end
def account
@assistant&.account
end
end
@@ -33,8 +33,8 @@ class Captain::Llm::ContactAttributesService < Llm::BaseAiService
def instrumentation_params
{
span_name: 'llm.captain.contact_attributes',
model: @model,
temperature: @temperature,
model: model,
temperature: temperature,
account_id: @conversation.account_id,
feature_name: 'contact_attributes',
messages: [
@@ -34,8 +34,8 @@ class Captain::Llm::ContactNotesService < Llm::BaseAiService
def instrumentation_params
{
span_name: 'llm.captain.contact_notes',
model: @model,
temperature: @temperature,
model: model,
temperature: temperature,
account_id: @conversation.account_id,
feature_name: 'contact_notes',
messages: [
@@ -97,8 +97,8 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
def instrumentation_params
{
span_name: 'llm.captain.conversation_faq',
model: @model,
temperature: @temperature,
model: model,
temperature: temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.id,
feature_name: 'conversation_faq',
@@ -33,8 +33,8 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
def instrumentation_params
{
span_name: 'llm.captain.faq_generator',
model: @model,
temperature: @temperature,
model: model,
temperature: temperature,
feature_name: 'faq_generator',
account_id: @account_id,
messages: [
@@ -72,7 +72,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
def instrumentation_params
{
span_name: 'llm.captain.website_analyzer',
model: @model,
model: model,
temperature: 0.1,
feature_name: 'website_analyzer',
messages: [
+19 -5
View File
@@ -2,27 +2,41 @@
# Base service for LLM operations using RubyLLM.
# New features should inherit from this class.
#
# Subclasses should implement:
# - feature_name: Returns the feature key (e.g., 'assistant', 'copilot') for model lookup
# - account: Returns the Account instance for per-account model configuration
#
# If these methods are not implemented, falls back to InstallationConfig or DEFAULT_MODEL.
class Llm::BaseAiService
DEFAULT_MODEL = Llm::Config::DEFAULT_MODEL
DEFAULT_TEMPERATURE = 1.0
attr_reader :model, :temperature
attr_reader :temperature
def initialize
Llm::Config.initialize!
setup_model
setup_temperature
end
def chat(model: @model, temperature: @temperature)
def model
@model ||= determine_model
end
def chat(model: self.model, temperature: @temperature)
RubyLLM.chat(model: model).with_temperature(temperature)
end
private
def setup_model
def determine_model
if respond_to?(:feature_name, true) && respond_to?(:account, true) && account.present?
account_model = account.captain_preferences.dig(:models, feature_name.to_s)
return account_model if account_model.present?
end
config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
@model = (config_value.presence || DEFAULT_MODEL)
config_value.presence || DEFAULT_MODEL
end
def setup_temperature
@@ -7,7 +7,7 @@
#
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
class Llm::LegacyBaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'
DEFAULT_MODEL = 'gpt-4.1-mini'
attr_reader :client, :model
@@ -1,7 +1,6 @@
module Enterprise::Integrations::OpenaiProcessorService
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand
make_friendly make_formal simplify].freeze
CACHEABLE_EVENTS = %w[label_suggestion].freeze
ALLOWED_EVENT_NAMES = (Integrations::LlmBaseService::ALLOWED_EVENT_NAMES + Integrations::LlmBaseService::LABEL_SUGGESTION_EVENTS).freeze
CACHEABLE_EVENTS = Integrations::LlmBaseService::LABEL_SUGGESTION_EVENTS.freeze
def label_suggestion_message
payload = label_suggestion_body
@@ -49,7 +48,7 @@ module Enterprise::Integrations::OpenaiProcessorService
def summarize_body
{
model: self.class::GPT_MODEL,
model: model_for_event,
messages: [
{ role: 'system',
content: prompt_from_file('summary', enterprise: true) },
@@ -65,7 +64,7 @@ module Enterprise::Integrations::OpenaiProcessorService
return value_from_cache if content.blank?
{
model: self.class::GPT_MODEL,
model: model_for_event,
messages: [
{
role: 'system',
+21 -1
View File
@@ -7,11 +7,24 @@ class Integrations::LlmBaseService
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
CACHEABLE_EVENTS = %w[].freeze
# Maps event names to feature keys in config/llm.yml
EDITOR_EVENTS = %w[rephrase fix_spelling_grammar shorten expand make_friendly make_formal simplify summarize reply_suggestion].freeze
LABEL_SUGGESTION_EVENTS = %w[label_suggestion].freeze
ALLOWED_EVENT_NAMES = EDITOR_EVENTS.freeze
pattr_initialize [:hook!, :event!]
def model_for_event
feature = feature_name_for_event
return self.class::GPT_MODEL unless feature && hook&.account
account_model = hook.account.captain_preferences.dig(:models, feature)
account_model.presence || self.class::GPT_MODEL
end
def perform
return nil unless valid_event_name?
@@ -25,6 +38,13 @@ class Integrations::LlmBaseService
private
def feature_name_for_event
return 'editor' if EDITOR_EVENTS.include?(event_name)
return 'label_suggestion' if LABEL_SUGGESTION_EVENTS.include?(event_name)
nil
end
def event_name
event['name']
end
+3 -3
View File
@@ -53,7 +53,7 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
def build_api_call_body(system_content, user_content = event['data']['content'])
{
model: GPT_MODEL,
model: model_for_event,
messages: [
{ role: 'system', content: system_content },
{ role: 'user', content: user_content }
@@ -115,7 +115,7 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
def summarize_body
{
model: GPT_MODEL,
model: model_for_event,
messages: [
{ role: 'system',
content: prompt_from_file('summary', enterprise: false) },
@@ -126,7 +126,7 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
def reply_suggestion_body
{
model: GPT_MODEL,
model: model_for_event,
messages: [
{ role: 'system',
content: prompt_from_file('reply', enterprise: false) }
+1 -1
View File
@@ -1,7 +1,7 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
class << self
def initialized?
@initialized ||= false
+43
View File
@@ -0,0 +1,43 @@
# frozen_string_literal: true
module Llm::Models
CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze
class << self
def providers = CONFIG['providers']
def models = CONFIG['models']
def features = CONFIG['features']
def feature_keys = CONFIG['features'].keys
def default_model_for(feature)
CONFIG.dig('features', feature.to_s, 'default')
end
def models_for(feature)
CONFIG.dig('features', feature.to_s, 'models') || []
end
def valid_model_for?(feature, model_name)
models_for(feature).include?(model_name.to_s)
end
def feature_config(feature_key)
feature = features[feature_key.to_s]
return nil unless feature
{
models: feature['models'].map do |model_name|
model = models[model_name]
{
id: model_name,
display_name: model['display_name'],
provider: model['provider'],
coming_soon: model['coming_soon'],
credit_multiplier: model['credit_multiplier']
}
end,
default: feature['default']
}
end
end
end
+6 -6
View File
@@ -111,13 +111,13 @@
"wavesurfer.js": "7.8.6"
},
"devDependencies": {
"@egoist/tailwindcss-icons": "^1.8.1",
"@egoist/tailwindcss-icons": "^1.9.0",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.3",
"@iconify-json/lucide": "^1.2.68",
"@iconify-json/ph": "^1.2.1",
"@iconify-json/ri": "^1.2.3",
"@iconify-json/teenyicons": "^1.2.1",
"@iconify-json/logos": "^1.2.10",
"@iconify-json/lucide": "^1.2.82",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/ri": "^1.2.6",
"@iconify-json/teenyicons": "^1.2.2",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@size-limit/file": "^8.2.4",
"@vitest/coverage-v8": "3.0.5",
+118 -76
View File
@@ -249,26 +249,26 @@ importers:
version: 7.8.6
devDependencies:
'@egoist/tailwindcss-icons':
specifier: ^1.8.1
version: 1.8.1(tailwindcss@3.4.13)
specifier: ^1.9.0
version: 1.9.0(tailwindcss@3.4.13)
'@histoire/plugin-vue':
specifier: 0.17.15
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@iconify-json/logos':
specifier: ^1.2.3
version: 1.2.3
specifier: ^1.2.10
version: 1.2.10
'@iconify-json/lucide':
specifier: ^1.2.68
version: 1.2.68
specifier: ^1.2.82
version: 1.2.82
'@iconify-json/ph':
specifier: ^1.2.1
version: 1.2.1
specifier: ^1.2.2
version: 1.2.2
'@iconify-json/ri':
specifier: ^1.2.3
version: 1.2.3
specifier: ^1.2.6
version: 1.2.6
'@iconify-json/teenyicons':
specifier: ^1.2.1
version: 1.2.1
specifier: ^1.2.2
version: 1.2.2
'@intlify/eslint-plugin-vue-i18n':
specifier: ^3.2.0
version: 3.2.0(eslint@8.57.0)
@@ -375,11 +375,11 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@antfu/install-pkg@0.4.1':
resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==}
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@0.7.10':
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
'@antfu/utils@8.1.1':
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
'@asamuzakjp/css-color@4.1.0':
resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==}
@@ -676,8 +676,8 @@ packages:
peerDependencies:
postcss-selector-parser: ^6.0.10
'@egoist/tailwindcss-icons@1.8.1':
resolution: {integrity: sha512-hqZeASrhT6BOeaBHYDPB0yBH/zgMKqmm7y2Rsq0c4iRnNVv1RWEiXMBMJB38JsDMTHME450FKa/wvdaIhED+Iw==}
'@egoist/tailwindcss-icons@1.9.0':
resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==}
peerDependencies:
tailwindcss: '*'
@@ -937,29 +937,29 @@ packages:
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead
'@iconify-json/logos@1.2.3':
resolution: {integrity: sha512-JLHS5hgZP1b55EONAWNeqBUuriRfRNKWXK4cqYx0PpVaJfIIMiiMxFfvoQiX/bkE9XgkLhcKmDUqL3LXPdXPwQ==}
'@iconify-json/logos@1.2.10':
resolution: {integrity: sha512-qxaXKJ6fu8jzTMPQdHtNxlfx6tBQ0jXRbHZIYy5Ilh8Lx9US9FsAdzZWUR8MXV8PnWTKGDFO4ZZee9VwerCyMA==}
'@iconify-json/lucide@1.2.68':
resolution: {integrity: sha512-lR5xNJdn2CT0iR7lM25G4SewBO4G2hbr3fTWOc3AE9BspflEcneh02E3l9TBaCU/JOHozTJevWLrxBGypD7Tng==}
'@iconify-json/lucide@1.2.82':
resolution: {integrity: sha512-fHZWegspOZonl5GNTvOkHsjnTMdSslFh3EzpzUtRyLxO8bOonqk2OTU3hCl0k4VXzViMjqpRK3X1sotnuBXkFA==}
'@iconify-json/material-symbols@1.2.10':
resolution: {integrity: sha512-GcZxhOFStM7Dk/oZvJSaW0tR/k6NwTq+KDzYgCNBDg52ktZuRa/gkjRiYooJm/8PAe9NBYxIx8XjS/wi4sasdQ==}
'@iconify-json/ph@1.2.1':
resolution: {integrity: sha512-x0DNfwWrS18dbsBYOq3XGiZnGz4CgRyC+YSl/TZvMQiKhIUl1woWqUbMYqqfMNUBzjyk7ulvaRovpRsIlqIf8g==}
'@iconify-json/ph@1.2.2':
resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==}
'@iconify-json/ri@1.2.3':
resolution: {integrity: sha512-UVKofd5xkSevGd5K01pvO4NWsu+2C9spu+GxnMZUYymUiaWmpCAxtd22MFSpm6MGf0MP4GCwhDCo1Q8L8oZ9wg==}
'@iconify-json/ri@1.2.6':
resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==}
'@iconify-json/teenyicons@1.2.1':
resolution: {integrity: sha512-PaVv+zrQEO6I/9YfEwxkJfYSrCIWyOoSv/ZOVgETsr0MOqN9k7ecnHF/lPrgpyCLkwLzPX7MyFm3/gmziwjSiw==}
'@iconify-json/teenyicons@1.2.2':
resolution: {integrity: sha512-Do08DrvNpT+pKVeyFqn7nZiIviAAY8KbduSfpNKzE1bgVekAIJ/AAJtOBSUFpV4vTk+hXw195+jmCv8/0cJSKA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@2.1.32':
resolution: {integrity: sha512-LeifFZPPKu28O3AEDpYJNdEbvS4/ojAPyIW+pF/vUpJTYnbTiXUHkCh0bwgFRzKvdpb8H4Fbfd/742++MF4fPQ==}
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@intlify/core-base@9.14.2':
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
@@ -1892,8 +1892,11 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
confbox@0.1.7:
resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.2:
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
config-chain@1.1.13:
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
@@ -2420,6 +2423,9 @@ packages:
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
engines: {node: '>=12.0.0'}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
extend-shallow@2.0.1:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
@@ -2615,6 +2621,10 @@ packages:
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
engines: {node: '>=18'}
globals@15.15.0:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
engines: {node: '>=18'}
globalthis@1.0.3:
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
engines: {node: '>= 0.4'}
@@ -3097,8 +3107,8 @@ packages:
lit@2.2.6:
resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==}
local-pkg@0.5.0:
resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==}
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
locate-path@5.0.0:
@@ -3277,8 +3287,8 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mlly@1.7.1:
resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
mpd-parser@0.21.0:
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
@@ -3460,8 +3470,8 @@ packages:
package-json-from-dist@1.0.0:
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
package-manager-detector@0.2.0:
resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==}
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
param-case@3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
@@ -3519,6 +3529,9 @@ packages:
pathe@2.0.2:
resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.0:
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
engines: {node: '>= 14.16'}
@@ -3569,8 +3582,11 @@ packages:
resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==}
engines: {node: '>=14.16'}
pkg-types@1.2.0:
resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==}
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
@@ -3892,6 +3908,9 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -4280,6 +4299,10 @@ packages:
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
tinykeys@3.0.0:
resolution: {integrity: sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==}
@@ -4399,8 +4422,8 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.5.4:
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
@@ -4849,12 +4872,12 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@antfu/install-pkg@0.4.1':
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 0.2.0
tinyexec: 0.3.2
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@antfu/utils@0.7.10': {}
'@antfu/utils@8.1.1': {}
'@asamuzakjp/css-color@4.1.0':
dependencies:
@@ -5172,9 +5195,9 @@ snapshots:
dependencies:
postcss-selector-parser: 6.1.1
'@egoist/tailwindcss-icons@1.8.1(tailwindcss@3.4.13)':
'@egoist/tailwindcss-icons@1.9.0(tailwindcss@3.4.13)':
dependencies:
'@iconify/utils': 2.1.32
'@iconify/utils': 2.3.0
tailwindcss: 3.4.13
transitivePeerDependencies:
- supports-color
@@ -5429,11 +5452,11 @@ snapshots:
'@humanwhocodes/object-schema@2.0.3': {}
'@iconify-json/logos@1.2.3':
'@iconify-json/logos@1.2.10':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/lucide@1.2.68':
'@iconify-json/lucide@1.2.82':
dependencies:
'@iconify/types': 2.0.0
@@ -5441,29 +5464,30 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ph@1.2.1':
'@iconify-json/ph@1.2.2':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ri@1.2.3':
'@iconify-json/ri@1.2.6':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/teenyicons@1.2.1':
'@iconify-json/teenyicons@1.2.2':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {}
'@iconify/utils@2.1.32':
'@iconify/utils@2.3.0':
dependencies:
'@antfu/install-pkg': 0.4.1
'@antfu/utils': 0.7.10
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@iconify/types': 2.0.0
debug: 4.4.0
debug: 4.4.3
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 0.5.0
mlly: 1.7.1
local-pkg: 1.1.2
mlly: 1.8.0
transitivePeerDependencies:
- supports-color
@@ -6135,8 +6159,7 @@ snapshots:
acorn@8.14.0: {}
acorn@8.15.0:
optional: true
acorn@8.15.0: {}
activestorage@5.2.8:
dependencies:
@@ -6537,7 +6560,9 @@ snapshots:
concat-map@0.0.1: {}
confbox@0.1.7: {}
confbox@0.1.8: {}
confbox@0.2.2: {}
config-chain@1.1.13:
dependencies:
@@ -7174,6 +7199,8 @@ snapshots:
expect-type@1.1.0: {}
exsolve@1.0.8: {}
extend-shallow@2.0.1:
dependencies:
is-extendable: 0.1.1
@@ -7391,6 +7418,8 @@ snapshots:
globals@15.14.0: {}
globals@15.15.0: {}
globalthis@1.0.3:
dependencies:
define-properties: 1.2.0
@@ -7961,10 +7990,11 @@ snapshots:
lit-element: 3.3.3
lit-html: 2.8.0
local-pkg@0.5.0:
local-pkg@1.1.2:
dependencies:
mlly: 1.7.1
pkg-types: 1.2.0
mlly: 1.8.0
pkg-types: 2.3.0
quansync: 0.2.11
locate-path@5.0.0:
dependencies:
@@ -8138,12 +8168,12 @@ snapshots:
mitt@3.0.1: {}
mlly@1.7.1:
mlly@1.8.0:
dependencies:
acorn: 8.12.1
pathe: 1.1.2
pkg-types: 1.2.0
ufo: 1.5.4
acorn: 8.15.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.1
mpd-parser@0.21.0:
dependencies:
@@ -8324,7 +8354,7 @@ snapshots:
package-json-from-dist@1.0.0: {}
package-manager-detector@0.2.0: {}
package-manager-detector@1.6.0: {}
param-case@3.0.4:
dependencies:
@@ -8376,6 +8406,8 @@ snapshots:
pathe@2.0.2: {}
pathe@2.0.3: {}
pathval@2.0.0: {}
perfect-debounce@1.0.0: {}
@@ -8409,11 +8441,17 @@ snapshots:
dependencies:
find-up: 6.3.0
pkg-types@1.2.0:
pkg-types@1.3.1:
dependencies:
confbox: 0.1.7
mlly: 1.7.1
pathe: 1.1.2
confbox: 0.1.8
mlly: 1.8.0
pathe: 2.0.3
pkg-types@2.3.0:
dependencies:
confbox: 0.2.2
exsolve: 1.0.8
pathe: 2.0.3
pngjs@5.0.0: {}
@@ -8794,6 +8832,8 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
quansync@0.2.11: {}
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}
@@ -9254,6 +9294,8 @@ snapshots:
tinyexec@0.3.2: {}
tinyexec@1.0.2: {}
tinykeys@3.0.0: {}
tinypool@1.0.2: {}
@@ -9384,7 +9426,7 @@ snapshots:
uc.micro@2.1.0: {}
ufo@1.5.4: {}
ufo@1.6.1: {}
unbox-primitive@1.0.2:
dependencies:
@@ -0,0 +1,50 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::Config', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/{account.id}/captain/config' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/captain/config",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns available AI model options for each feature' do
get "/api/v1/accounts/#{account.id}/captain/config",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
%w[editor assistant copilot label_suggestion].each do |feature|
expect(json_response.dig(:features, feature.to_sym, :models)).to be_present
expect(json_response.dig(:features, feature.to_sym, :default)).to be_present
end
end
end
context 'when it is an admin' do
it 'returns providers, models, and features configuration' do
get "/api/v1/accounts/#{account.id}/captain/config",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response).to have_key(:providers)
expect(json_response).to have_key(:models)
expect(json_response).to have_key(:features)
end
end
end
end
@@ -9,7 +9,7 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
before do
# Create required installation configs
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-mini')
# Mock usage limits for transcription to be available
allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
@@ -20,7 +20,7 @@ RSpec.describe Integrations::LlmInstrumentation do
account_id: 123,
conversation_id: 456,
feature_name: 'reply_suggestion',
model: 'gpt-4o-mini',
model: 'gpt-4.1-mini',
messages: [{ 'role' => 'user', 'content' => 'Hello' }],
temperature: 0.7
}
@@ -105,7 +105,7 @@ RSpec.describe Integrations::LlmInstrumentation do
instance.instrument_llm_call(params) { 'result' }
expect(mock_span).to have_received(:set_attribute).with('gen_ai.provider.name', 'openai')
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.model', 'gpt-4o-mini')
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.model', 'gpt-4.1-mini')
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.temperature', 0.7)
end
+55
View File
@@ -218,4 +218,59 @@ RSpec.describe Account do
end
end
end
describe 'captain_preferences' do
let(:account) { create(:account) }
describe 'with no saved preferences' do
it 'returns defaults from llm.yml' do
prefs = account.captain_preferences
expect(prefs[:features].values).to all(be false)
Llm::Models.feature_keys.each do |feature|
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
end
end
end
describe 'with saved model preferences' do
it 'returns saved preferences merged with defaults' do
account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' })
prefs = account.captain_preferences
expect(prefs[:models]['editor']).to eq('gpt-4.1-mini')
expect(prefs[:models]['assistant']).to eq('gpt-5.2')
expect(prefs[:models]['copilot']).to eq(Llm::ConfigService.default_model_for_feature('copilot'))
end
end
describe 'with saved feature preferences' do
it 'returns saved feature states' do
account.update!(captain_features: { 'editor' => true, 'assistant' => true })
prefs = account.captain_preferences
expect(prefs[:features]['editor']).to be true
expect(prefs[:features]['assistant']).to be true
expect(prefs[:features]['copilot']).to be false
end
end
describe 'validation' do
it 'rejects invalid model for a feature' do
account.captain_models = { 'label_suggestion' => 'gpt-5.1' }
expect(account).not_to be_valid
expect(account.errors[:captain_models].first).to include('not a valid model for label_suggestion')
end
it 'accepts valid model for a feature' do
account.captain_models = { 'editor' => 'gpt-4.1-mini', 'label_suggestion' => 'gpt-4.1-nano' }
expect(account).to be_valid
end
end
end
end
@@ -0,0 +1,134 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CaptainFeaturable do
let(:account) { create(:account) }
describe 'dynamic method generation' do
it 'generates enabled? methods for all features' do
Llm::Models.feature_keys.each do |feature_key|
expect(account).to respond_to("captain_#{feature_key}_enabled?")
end
end
it 'generates model accessor methods for all features' do
Llm::Models.feature_keys.each do |feature_key|
expect(account).to respond_to("captain_#{feature_key}_model")
end
end
end
describe 'feature enabled methods' do
context 'when no features are explicitly enabled' do
it 'returns false for all features' do
Llm::Models.feature_keys.each do |feature_key|
expect(account.send("captain_#{feature_key}_enabled?")).to be false
end
end
end
context 'when features are explicitly enabled' do
before do
account.update!(captain_features: { 'editor' => true, 'assistant' => true })
end
it 'returns true for enabled features' do
expect(account.captain_editor_enabled?).to be true
expect(account.captain_assistant_enabled?).to be true
end
it 'returns false for disabled features' do
expect(account.captain_copilot_enabled?).to be false
expect(account.captain_label_suggestion_enabled?).to be false
end
end
context 'when captain_features is nil' do
before do
account.update!(captain_features: nil)
end
it 'returns false for all features' do
Llm::Models.feature_keys.each do |feature_key|
expect(account.send("captain_#{feature_key}_enabled?")).to be false
end
end
end
end
describe 'model accessor methods' do
context 'when no models are explicitly configured' do
it 'returns default models for all features' do
Llm::Models.feature_keys.each do |feature_key|
expected_default = Llm::Models.default_model_for(feature_key)
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
end
end
end
context 'when models are explicitly configured' do
before do
account.update!(captain_models: {
'editor' => 'gpt-4.1-mini',
'assistant' => 'gpt-5.1',
'label_suggestion' => 'gpt-4.1-nano'
})
end
it 'returns configured models for configured features' do
expect(account.captain_editor_model).to eq('gpt-4.1-mini')
expect(account.captain_assistant_model).to eq('gpt-5.1')
expect(account.captain_label_suggestion_model).to eq('gpt-4.1-nano')
end
it 'returns default models for unconfigured features' do
expect(account.captain_copilot_model).to eq(Llm::Models.default_model_for('copilot'))
expect(account.captain_audio_transcription_model).to eq(Llm::Models.default_model_for('audio_transcription'))
end
end
context 'when configured with invalid model' do
before do
account.captain_models = { 'editor' => 'invalid-model' }
end
it 'falls back to default model' do
expect(account.captain_editor_model).to eq(Llm::Models.default_model_for('editor'))
end
end
context 'when captain_models is nil' do
before do
account.update!(captain_models: nil)
end
it 'returns default models for all features' do
Llm::Models.feature_keys.each do |feature_key|
expected_default = Llm::Models.default_model_for(feature_key)
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
end
end
end
end
describe 'integration with existing captain_preferences' do
it 'enabled? methods use the same logic as captain_preferences[:features]' do
account.update!(captain_features: { 'editor' => true, 'copilot' => true })
prefs = account.captain_preferences
Llm::Models.feature_keys.each do |feature_key|
expect(account.send("captain_#{feature_key}_enabled?")).to eq(prefs[:features][feature_key])
end
end
it 'model methods use the same logic as captain_preferences[:models]' do
account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' })
prefs = account.captain_preferences
Llm::Models.feature_keys.each do |feature_key|
expect(account.send("captain_#{feature_key}_model")).to eq(prefs[:models][feature_key])
end
end
end
end
+267 -1
View File
@@ -3,7 +3,9 @@ require 'rails_helper'
describe CsatSurveyService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) }
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :resolved) }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '+1234567890') }
let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: inbox, account: account, status: :resolved) }
let(:service) { described_class.new(conversation: conversation) }
describe '#perform' do
@@ -87,5 +89,269 @@ describe CsatSurveyService do
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
end
end
context 'when it is a WhatsApp channel' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account, csat_survey_enabled: true) }
let(:whatsapp_contact) { create(:contact, account: account) }
let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: whatsapp_contact, inbox: whatsapp_inbox, source_id: '1234567890') }
let(:whatsapp_conversation) do
create(:conversation, contact_inbox: whatsapp_contact_inbox, inbox: whatsapp_inbox, account: account, status: :resolved)
end
let(:whatsapp_service) { described_class.new(conversation: whatsapp_conversation) }
let(:mock_provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_provider_service)
allow(whatsapp_conversation).to receive(:can_reply?).and_return(true)
end
context 'when template is available and approved' do
before do
setup_approved_template('customer_survey_template')
end
it 'sends WhatsApp template survey instead of regular survey' do
mock_successful_template_send('template_message_id_123')
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template).with(
'1234567890',
hash_including(
name: 'customer_survey_template',
lang_code: 'en',
parameters: array_including(
hash_including(
type: 'button',
sub_type: 'url',
index: '0',
parameters: array_including(
hash_including(type: 'text', text: whatsapp_conversation.uuid)
)
)
)
),
instance_of(Message)
)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
it 'updates message with returned message ID' do
mock_successful_template_send('template_message_id_123')
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to eq('template_message_id_123')
end
it 'builds correct template info with default template name' do
expected_template_name = "customer_satisfaction_survey_#{whatsapp_inbox.id}"
whatsapp_inbox.update(csat_config: { 'template' => {}, 'message' => 'Rate us' })
allow(mock_provider_service).to receive(:get_template_status)
.with(expected_template_name)
.and_return({ success: true, template: { status: 'APPROVED' } })
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
'msg_id'
end
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template).with(
'1234567890',
hash_including(
name: expected_template_name,
lang_code: 'en'
),
anything
)
end
it 'builds CSAT message with correct attributes' do
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
'msg_id'
end
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.account).to eq(account)
expect(csat_message.inbox).to eq(whatsapp_inbox)
expect(csat_message.message_type).to eq('outgoing')
expect(csat_message.content).to eq('Please rate your experience')
expect(csat_message.content_type).to eq('input_csat')
end
it 'uses default message when not configured' do
setup_approved_template('test', { 'template' => { 'name' => 'test' } })
mock_successful_template_send('msg_id')
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.content).to eq('Please rate this conversation')
end
end
context 'when template is not available or not approved' do
it 'falls back to regular survey when template is pending' do
setup_template_with_status('pending_template', 'PENDING')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template is rejected' do
setup_template_with_status('pending_template', 'REJECTED')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template API call fails' do
allow(mock_provider_service).to receive(:get_template_status)
.with('pending_template')
.and_return({ success: false, error: 'Template not found' })
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template status check raises error' do
allow(mock_provider_service).to receive(:get_template_status)
.and_raise(StandardError, 'API connection failed')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
end
context 'when no template is configured' do
it 'falls back to regular survey' do
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
end
context 'when template sending fails' do
before do
setup_approved_template('working_template', {
'template' => { 'name' => 'working_template' },
'message' => 'Rate us'
})
end
it 'handles template sending errors gracefully' do
mock_template_send_failure('Template send failed')
expect { whatsapp_service.perform }.not_to raise_error
# Should still create the CSAT message even if sending fails
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to be_nil
end
it 'does not update message when send_template returns nil' do
mock_template_send_with_no_id
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to be_nil
end
end
context 'when outside messaging window' do
before do
allow(whatsapp_conversation).to receive(:can_reply?).and_return(false)
end
it 'sends template survey even when outside messaging window if template is approved' do
setup_approved_template('approved_template', { 'template' => { 'name' => 'approved_template' } })
mock_successful_template_send('msg_id')
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
# No activity message should be created when template is successfully sent
end
it 'creates activity message when template is not available and outside window' do
whatsapp_service.perform
expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with(
whatsapp_conversation,
hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window'))
)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
end
end
end
private
def setup_approved_template(template_name, config = nil)
template_config = config || {
'template' => {
'name' => template_name,
'language' => 'en'
},
'message' => 'Please rate your experience'
}
whatsapp_inbox.update(csat_config: template_config)
allow(mock_provider_service).to receive(:get_template_status)
.with(template_name)
.and_return({ success: true, template: { status: 'APPROVED' } })
end
def setup_template_with_status(template_name, status)
whatsapp_inbox.update(csat_config: {
'template' => { 'name' => template_name }
})
allow(mock_provider_service).to receive(:get_template_status)
.with(template_name)
.and_return({ success: true, template: { status: status } })
end
def mock_successful_template_send(message_id)
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
message_id
end
end
def mock_template_send_failure(error_message = 'Template send failed')
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
raise StandardError, error_message
end
end
def mock_template_send_with_no_id
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
nil
end
end
end
@@ -74,6 +74,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Instagram' do
@@ -130,6 +148,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Line' do
@@ -358,6 +394,18 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result).to include('1. first step')
expect(result).to include('2. second step')
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::TwilioSms' do
@@ -81,7 +81,29 @@ RSpec.describe Twilio::TemplateSyncService do
)
end
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] }
let(:call_to_action_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX444555666',
friendly_name: 'payment_reminder',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/call-to-action' => {
'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
'actions' => [
{ 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' }
]
}
}
)
end
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] }
before do
allow(twilio_channel).to receive(:send).and_call_original
@@ -104,7 +126,7 @@ RSpec.describe Twilio::TemplateSyncService do
twilio_channel.reload
expect(twilio_channel.content_templates).to be_present
expect(twilio_channel.content_templates['templates']).to be_an(Array)
expect(twilio_channel.content_templates['templates'].size).to eq(4)
expect(twilio_channel.content_templates['templates'].size).to eq(5)
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
end
end
@@ -172,6 +194,32 @@ RSpec.describe Twilio::TemplateSyncService do
)
end
it 'correctly formats call-to-action templates with variables' do
sync_service.call
twilio_channel.reload
call_to_action_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'payment_reminder'
end
expect(call_to_action_data).to include(
'content_sid' => 'HX444555666',
'friendly_name' => 'payment_reminder',
'language' => 'en',
'status' => 'approved',
'template_type' => 'call_to_action',
'media_type' => nil,
'variables' => {},
'category' => 'utility'
)
expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience'
expect(call_to_action_data['body']).to eq(expected_body)
expect(call_to_action_data['body']).to match(/{{date}}/)
end
it 'categorizes marketing templates correctly' do
marketing_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
+101
View File
@@ -184,5 +184,106 @@ export const icons = {
width: 14,
height: 14,
},
gemini: {
width: 32,
height: 32,
body: `<defs>
<filter id="SVGqoIxVV2h" width="39.274" height="43.217" x="-19.824" y="13.152" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="2.46" />
</filter>
<filter id="SVGOahAkcjC" width="84.868" height="85.688" x="-15.001" y="-40.257" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="11.891" />
</filter>
<filter id="SVGyT4fLePl" width="79.454" height="90.917" x="-20.776" y="11.927" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="10.109" />
</filter>
<filter id="SVGonSETbRF" width="79.731" height="81.505" x="-19.845" y="15.459" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="10.109" />
</filter>
<filter id="SVGSN7ofz6B" width="75.117" height="73.758" x="29.832" y="-11.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="9.606" />
</filter>
<filter id="SVGHvbpPvOn" width="78.135" height="78.758" x="-38.583" y="-16.253" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="8.706" />
</filter>
<filter id="SVG7JmfweRd" width="78.877" height="77.539" x="8.107" y="-5.966" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="7.775" />
</filter>
<filter id="SVGgGkiybCN" width="56.272" height="51.81" x="13.587" y="-18.488" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="6.957" />
</filter>
<filter id="SVGxEY6lcrm" width="70.856" height="69.306" x="-15.526" y="-31.297" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="5.876" />
</filter>
<filter id="SVGg29FyG4g" width="55.501" height="51.571" x="-14.168" y="20.964" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="7.273" />
</filter>
<linearGradient id="SVGlOwgwsgJ" x1="18.447" x2="52.153" y1="43.42" y2="15.004" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4893fc" />
<stop offset=".27" stop-color="#4893fc" />
<stop offset=".777" stop-color="#969dff" />
<stop offset="1" stop-color="#bd99fe" />
</linearGradient>
<mask id="SVGbqsmycuA" width="65" height="65" x="0" y="0" maskUnits="userSpaceOnUse">
<path d="M32.447 0c.68 0 1.272.465 1.438 1.125a39 39 0 0 0 2 5.905q3.23 7.5 8.854 13.125q5.626 5.626 13.125 8.855a39 39 0 0 0 5.905 1.999c.66.166 1.125.758 1.125 1.438s-.464 1.273-1.125 1.439a39 39 0 0 0-5.905 1.999q-7.5 3.23-13.125 8.854q-5.625 5.627-8.854 13.125a39 39 0 0 0-2 5.906a1.485 1.485 0 0 1-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a39 39 0 0 0-2-5.905q-3.228-7.5-8.854-13.125T7.03 35.885a39 39 0 0 0-5.905-2A1.485 1.485 0 0 1 0 32.448c0-.68.465-1.272 1.125-1.438a39 39 0 0 0 5.905-2q7.5-3.229 13.125-8.854C25.78 14.53 26.857 12.03 29.01 7.03a39 39 0 0 0 1.999-5.905A1.485 1.485 0 0 1 32.447 0" />
<path fill="url(#SVGlOwgwsgJ)" d="M32.447 0c.68 0 1.272.465 1.438 1.125a39 39 0 0 0 2 5.905q3.23 7.5 8.854 13.125q5.626 5.626 13.125 8.855a39 39 0 0 0 5.905 1.999c.66.166 1.125.758 1.125 1.438s-.464 1.273-1.125 1.439a39 39 0 0 0-5.905 1.999q-7.5 3.23-13.125 8.854q-5.625 5.627-8.854 13.125a39 39 0 0 0-2 5.906a1.485 1.485 0 0 1-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a39 39 0 0 0-2-5.905q-3.228-7.5-8.854-13.125T7.03 35.885a39 39 0 0 0-5.905-2A1.485 1.485 0 0 1 0 32.448c0-.68.465-1.272 1.125-1.438a39 39 0 0 0 5.905-2q7.5-3.229 13.125-8.854C25.78 14.53 26.857 12.03 29.01 7.03a39 39 0 0 0 1.999-5.905A1.485 1.485 0 0 1 32.447 0" />
</mask>
</defs>
<g mask="url(#SVGbqsmycuA)" transform="translate(2.15 2.15)scale(.42687)">
<g filter="url(#SVGqoIxVV2h)">
<ellipse cx="14.407" cy="16.95" fill="#ffe432" rx="14.407" ry="16.95" transform="rotate(19.551 -44.575 -16.496)" />
</g>
<g filter="url(#SVGOahAkcjC)">
<ellipse cx="27.433" cy="2.587" fill="#fc413d" rx="18.652" ry="19.062" />
</g>
<g filter="url(#SVGyT4fLePl)">
<ellipse cx="18.951" cy="57.386" fill="#00b95c" rx="19.493" ry="25.253" transform="rotate(-2.799 18.951 57.386)" />
</g>
<g filter="url(#SVGyT4fLePl)">
<ellipse cx="18.951" cy="57.386" fill="#00b95c" rx="19.493" ry="25.253" transform="rotate(-2.799 18.951 57.386)" />
</g>
<g filter="url(#SVGonSETbRF)">
<ellipse cx="20.02" cy="56.211" fill="#00b95c" rx="19.107" ry="21.034" transform="rotate(-31.318 20.02 56.211)" />
</g>
<g filter="url(#SVGSN7ofz6B)">
<ellipse cx="67.391" cy="25.327" fill="#3186ff" rx="18.346" ry="17.667" />
</g>
<g filter="url(#SVGHvbpPvOn)">
<ellipse cx="21.222" cy="22.384" fill="#fbbc04" rx="21.222" ry="22.384" transform="rotate(37.252 9.752 -8.009)" />
</g>
<g filter="url(#SVG7JmfweRd)">
<ellipse cx="24.469" cy="22.604" fill="#3186ff" rx="24.469" ry="22.604" transform="rotate(34.51 19.587 64.852)" />
</g>
<g filter="url(#SVGgGkiybCN)">
<path fill="#749bff" d="M54.984-2.336c2.833 3.852-.807 11.34-8.13 16.728c-7.325 5.386-15.558 6.63-18.39 2.779c-2.834-3.852.806-11.341 8.13-16.728c7.323-5.387 15.557-6.631 18.39-2.78z" />
</g>
<g filter="url(#SVGxEY6lcrm)">
<ellipse cx="19.902" cy="3.356" fill="#fc413d" rx="27.971" ry="17.388" transform="rotate(-42.848 19.902 3.356)" />
</g>
<g filter="url(#SVGg29FyG4g)">
<ellipse cx="13.583" cy="46.75" fill="#ffee48" rx="14.989" ry="8.717" transform="rotate(35.592 13.583 46.75)" />
</g>
</g>`,
},
/** Ends */
};