Compare commits

..
Author SHA1 Message Date
Aakash BakhleandGitHub 6343cd1631 Merge branch 'develop' into feature/cw-6123 2026-01-06 14:23:50 +05:30
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
aakashb95 7fd7704fea Merge branch 'develop' into feature/cw-6123 2025-12-23 10:28:14 +05:30
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
120 changed files with 2185 additions and 4334 deletions
-44
View File
@@ -218,49 +218,6 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -277,7 +234,6 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
- run:
+2 -2
View File
@@ -191,10 +191,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.8.0'
gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.9.1'
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
+18 -18
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.8.0)
ruby_llm (~> 1.9.1)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -297,7 +297,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.0)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -306,14 +306,14 @@ GEM
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -463,7 +463,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.18.0)
json (2.13.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -538,7 +538,7 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (1.0.4)
maxminddb (0.1.22)
meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
@@ -556,12 +556,12 @@ GEM
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.4.1)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.9.1)
uri (>= 0.11.1)
net-http (0.6.0)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
@@ -822,7 +822,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.9.1)
ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -966,7 +966,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.1.1)
uri (1.0.4)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1001,7 +1001,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.4)
zeitwerk (2.6.17)
PLATFORMS
arm64-darwin-20
@@ -1021,7 +1021,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.8.0)
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1123,7 +1123,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.9.1)
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
+2 -1
View File
@@ -8,6 +8,7 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@@ -136,4 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
@@ -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
@@ -28,7 +28,5 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
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();
+4 -14
View File
@@ -14,48 +14,38 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q, page = 1, since, until }) {
contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
since,
until,
},
});
}
conversations({ q, page = 1, since, until }) {
conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
since,
until,
},
});
}
messages({ q, page = 1, since, until, from, inboxId }) {
messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
since,
until,
from,
inbox_id: inboxId,
},
});
}
articles({ q, page = 1, since, until }) {
articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
since,
until,
},
});
}
@@ -1,134 +0,0 @@
import searchAPI from '../search';
import ApiClient from '../ApiClient';
describe('#SearchAPI', () => {
it('creates correct instance', () => {
expect(searchAPI).toBeInstanceOf(ApiClient);
expect(searchAPI).toHaveProperty('get');
expect(searchAPI).toHaveProperty('contacts');
expect(searchAPI).toHaveProperty('conversations');
expect(searchAPI).toHaveProperty('messages');
expect(searchAPI).toHaveProperty('articles');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
vi.clearAllMocks();
});
it('#get', () => {
searchAPI.get({ q: 'test query' });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
params: { q: 'test query' },
});
});
it('#contacts', () => {
searchAPI.contacts({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#contacts with date filters', () => {
searchAPI.contacts({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
it('#conversations', () => {
searchAPI.conversations({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: undefined, until: undefined },
}
);
});
it('#conversations with date filters', () => {
searchAPI.conversations({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
}
);
});
it('#messages', () => {
searchAPI.messages({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: undefined,
until: undefined,
from: undefined,
inbox_id: undefined,
},
});
});
it('#messages with all filters', () => {
searchAPI.messages({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inboxId: 10,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inbox_id: 10,
},
});
});
it('#articles', () => {
searchAPI.articles({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#articles with date filters', () => {
searchAPI.articles({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
});
});
@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
menuItems: {
@@ -38,13 +37,9 @@ const props = defineProps({
type: String,
default: '',
},
disableLocalFiltering: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'search']);
const emit = defineEmits(['action']);
const { t } = useI18n();
@@ -62,7 +57,6 @@ const flattenedMenuItems = computed(() => {
});
const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
@@ -75,7 +69,7 @@ const filteredMenuSections = computed(() => {
return [];
}
if (props.disableLocalFiltering || !searchQuery.value) {
if (!searchQuery.value) {
return props.menuSections;
}
@@ -95,12 +89,6 @@ const filteredMenuSections = computed(() => {
.filter(section => section.items.length > 0);
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
@@ -130,7 +118,7 @@ onMounted(() => {
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
@@ -142,7 +130,6 @@ onMounted(() => {
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
</div>
@@ -154,23 +141,10 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
@@ -261,6 +235,5 @@ onMounted(() => {
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
</div>
</template>
@@ -42,10 +42,7 @@ const props = defineProps({
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, {
deep: true,
stopPaths: ['content_attributes.translations'],
});
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
@@ -17,10 +17,6 @@ const { attachment } = defineProps({
type: Object,
required: true,
},
showTranscribedText: {
type: Boolean,
default: true,
},
});
defineOptions({
@@ -186,7 +182,7 @@ const downloadAudio = async () => {
</div>
<div
v-if="attachment.transcribedText && showTranscribedText"
v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
@@ -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'),
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, onMounted, nextTick, watch } from 'vue';
import { computed, ref, onMounted, nextTick } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({
@@ -31,24 +31,20 @@ const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
nextTick(() => {
if (!activeElement.value) return;
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
});
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
};
useResizeObserver(activeElement, updateIndicator);
// Watch for prop/tabs changes to update indicator position
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
immediate: true,
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
@@ -70,7 +66,7 @@ const showDivider = index => {
<template>
<div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 dark:bg-n-solid-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
@@ -55,7 +55,6 @@ import {
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -132,10 +131,8 @@ const editorMenuOptions = computed(() => {
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
const sanitizedContent = stripUnsupportedFormatting(content, schema);
return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({
schema,
placeholder,
@@ -7,7 +7,9 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -44,6 +46,7 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -69,6 +72,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -109,6 +114,8 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -141,6 +148,9 @@ export default {
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
@@ -399,6 +409,19 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -441,7 +464,25 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message() {
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -491,14 +532,20 @@ export default {
methods: {
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
@@ -567,14 +614,26 @@ export default {
if (this.isPrivate) {
return message;
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -590,6 +649,7 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -634,6 +694,11 @@ export default {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return;
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
@@ -767,15 +832,19 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
}
const updatedMessage = replaceVariablesInMessage({
@@ -799,30 +868,52 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
this.updateEditorSelectionWith = content;
this.onFocus();
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -857,6 +948,9 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1103,6 +1197,13 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1126,7 +1227,23 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
@@ -1252,6 +1369,10 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1271,4 +1392,9 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
@@ -1,7 +1,6 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
items: {
@@ -16,8 +15,6 @@ const props = defineProps({
const emit = defineEmits(['mentionSelect']);
const { getPlainText } = useMessageFormatter();
const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
@@ -97,7 +94,7 @@ const variableKey = (item = {}) => {
'text-n-slate-12': index === selectedIndex,
}"
>
{{ getPlainText(item.description) }}
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"
@@ -1,31 +1,39 @@
import { selectTranslation } from '../useTranslations';
import { ref } from 'vue';
import { useTranslations } from '../useTranslations';
describe('selectTranslation', () => {
it('returns null when translations is null', () => {
expect(selectTranslation(null, 'en', 'en')).toBeNull();
describe('useTranslations', () => {
it('returns false and null when contentAttributes is null', () => {
const contentAttributes = ref(null);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns null when translations is empty', () => {
expect(selectTranslation({}, 'en', 'en')).toBeNull();
it('returns false and null when translations are missing', () => {
const contentAttributes = ref({});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns first translation when no locale matches', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
it('returns false and null when translations is an empty object', () => {
const contentAttributes = ref({ translations: {} });
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
});
it('returns translation matching agent locale', () => {
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
});
it('falls back to account locale when agent locale not found', () => {
const translations = { en: 'Hello', zh_CN: '你好' };
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
});
it('returns first translation when both locales are undefined', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
it('returns true and correct translation content when translations exist', () => {
const contentAttributes = ref({
translations: { en: 'Hello' },
});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(true);
// Should return the first translation (en: 'Hello')
expect(translationContent.value).toBe('Hello');
});
});
@@ -1,25 +1,4 @@
import { computed } from 'vue';
import { useUISettings } from './useUISettings';
import { useAccount } from './useAccount';
/**
* Select translation based on locale priority.
* @param {Object} translations - Translations object with locale keys
* @param {string} agentLocale - Agent's preferred locale
* @param {string} accountLocale - Account's default locale
* @returns {string|null} Selected translation or null
*/
export function selectTranslation(translations, agentLocale, accountLocale) {
if (!translations || Object.keys(translations).length === 0) return null;
if (agentLocale && translations[agentLocale]) {
return translations[agentLocale];
}
if (accountLocale && translations[accountLocale]) {
return translations[accountLocale];
}
return translations[Object.keys(translations)[0]];
}
/**
* Composable to extract translation state/content from contentAttributes.
@@ -27,9 +6,6 @@ export function selectTranslation(translations, agentLocale, accountLocale) {
* @returns {Object} { hasTranslations, translationContent }
*/
export function useTranslations(contentAttributes) {
const { uiSettings } = useUISettings();
const { currentAccount } = useAccount();
const hasTranslations = computed(() => {
if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value;
@@ -38,11 +14,8 @@ export function useTranslations(contentAttributes) {
const translationContent = computed(() => {
if (!hasTranslations.value) return null;
return selectTranslation(
contentAttributes.value.translations,
uiSettings.value?.locale,
currentAccount.value?.locale
);
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
});
return { hasTranslations, translationContent };
+2 -5
View File
@@ -237,11 +237,8 @@ export const MARKDOWN_PATTERNS = [
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
},
{
type: 'link', // PM: link, eg: [text](url) or <url>
patterns: [
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
{ pattern: /<(https?:\/\/[^>]+)>/g, replacement: '$1' }, // <url> -> url (autolinks)
],
type: 'link', // PM: link, eg: [text](url)
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
},
];
@@ -5,5 +5,4 @@ export const LOCAL_STORAGE_KEYS = {
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
};
-1
View File
@@ -43,7 +43,6 @@ export const FEATURE_FLAGS = {
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
};
export const PREMIUM_FEATURES = [
+45 -17
View File
@@ -34,7 +34,6 @@ export function extractTextFromMarkdown(markdown) {
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
@@ -44,16 +43,35 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const supported = [...marks, ...nodes];
const has = (arr, key) => arr.includes(key);
// Apply patterns from MARKDOWN_PATTERNS for unsupported types
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
if (supported.includes(type)) return text;
return patterns.reduce(
(t, { pattern, replacement }) => t.replace(pattern, replacement),
text
);
}, markdown);
// Define stripping rules: [condition, pattern, replacement]
const rules = [
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
[
!has(marks, 'em'),
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
'$1',
],
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
];
const result = rules.reduce(
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
return result
.split('\n')
@@ -168,22 +186,27 @@ export function appendSignature(body, signature, channelType) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries multiple signature variants: original, channel-stripped, and fully stripped.
* Tries to find both the original signature and the stripped version.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature, channelType) {
// Build unique list of signature variants to try
// Build list of signatures to try: original, channel-stripped, and fully stripped
const cleanedSignature = cleanSignature(signature);
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
: null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [
cleanSignature(signature),
cleanedSignature,
channelStripped,
cleanSignature(extractTextFromMarkdown(signature)),
fullyStripped,
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
@@ -202,12 +225,17 @@ export function removeSignature(body, signature, channelType) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
}
// Remove delimiter if it's at the end
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
// let's find the delimiter and remove it
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
if (
delimiterIndex !== -1 &&
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
) {
// if the delimiter is at the end, remove it
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
newBody = newBody.substring(0, delimiterIndex);
}
// return the value
return newBody;
}
@@ -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) {
@@ -896,11 +896,6 @@ describe('stripUnsupportedFormatting', () => {
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves autolinks when schema supports links', () => {
const content = 'Check out <https://cegrafic.com/catalogo/>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
@@ -972,18 +967,6 @@ describe('stripUnsupportedFormatting', () => {
).toBe('Check this link');
});
it('converts autolinks to plain URLs when schema does not support links', () => {
const content = 'Visit <https://cegrafic.com/catalogo/> for more info';
const expected = 'Visit https://cegrafic.com/catalogo/ for more info';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles multiple autolinks in content', () => {
const content = 'Check <https://example.com> and <https://test.com>';
const expected = 'Check https://example.com and https://test.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
@@ -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"
}
}
@@ -1,7 +1,7 @@
{
"SEARCH": {
"TABS": {
"ALL": "All results",
"ALL": "All",
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus",
"PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot",
"READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:",
"FROM": "From",
"EMAIL": "Email",
"EMAIL_SUBJECT": "Subject",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
"FROM": "from",
"EMAIL": "email",
"EMAIL_SUBJECT": "subject"
}
}
@@ -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",
@@ -62,7 +62,6 @@ export default {
...mapGetters({
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
getUISettings: 'getUISettings',
}),
plainTextContent() {
return this.getPlainText(this.messageContent);
@@ -118,13 +117,11 @@ export default {
this.$emit('close', e);
},
handleTranslate() {
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
const agentLocale = this.getUISettings?.locale;
const targetLanguage = agentLocale || accountLocale || 'en';
const { locale } = this.getAccount(this.currentAccountId);
this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
targetLanguage: locale || 'en',
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
this.handleClose();
@@ -1,8 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ref, useTemplateRef, onMounted, watch, nextTick, computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useExpandableContent } from 'shared/composables/useExpandableContent';
import ReadMore from './ReadMore.vue';
const props = defineProps({
author: {
@@ -19,12 +18,6 @@ const props = defineProps({
},
});
const { t } = useI18n();
const { highlightContent } = useMessageFormatter();
const { contentElement, showReadMore, showReadLess, toggleExpanded } =
useExpandableContent();
const messageContent = computed(() => {
// We perform search on either content or email subject or transcribed text
if (props.message.content) {
@@ -37,19 +30,33 @@ const messageContent = computed(() => {
return email.subject;
}
const audioAttachment = props.message.attachments?.find(
const audioAttachment = props.message.attachments.find(
attachment => attachment.file_type === 'audio'
);
return audioAttachment?.transcribed_text || '';
});
const escapeHtml = html => {
const wrapper = document.createElement('p');
wrapper.textContent = html;
return wrapper.textContent;
const { highlightContent } = useMessageFormatter();
const messageContainer = useTemplateRef('messageContainer');
const isOverflowing = ref(false);
const setOverflow = () => {
const wrap = messageContainer.value;
if (wrap) {
const message = wrap.querySelector('.message-content');
isOverflowing.value = message.offsetHeight > 150;
}
};
const highlightedContent = computed(() => {
const escapeHtml = html => {
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
};
const prepareContent = () => {
const content = messageContent.value || '';
const escapedText = escapeHtml(content);
return highlightContent(
@@ -57,59 +64,50 @@ const highlightedContent = computed(() => {
props.searchTerm,
'searchkey--highlight'
);
});
};
const authorText = computed(() => {
const author = props.author || '';
const wroteText = t('SEARCH.WROTE');
return author ? `${author} ${wroteText} ` : '';
onMounted(() => {
watch(() => {
return messageContainer.value;
}, setOverflow);
nextTick(setOverflow);
});
</script>
<template>
<div
ref="contentElement"
class="break-words grid items-center text-n-slate-11 text-sm leading-relaxed"
:class="showReadMore ? 'grid-cols-[1fr_auto]' : 'grid-cols-1'"
>
<div
class="min-w-0"
:class="{
'overflow-hidden whitespace-nowrap text-ellipsis': showReadMore,
}"
>
<span v-if="authorText" class="text-n-slate-11 font-medium leading-4">{{
authorText
}}</span>
<span
v-dompurify-html="highlightedContent"
class="message-content text-n-slate-12 [&_.searchkey--highlight]:text-n-slate-12 [&_.searchkey--highlight]:font-semibold"
/>
<button
v-if="showReadLess"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium ltr:ml-0.5 rtl:mr-0.5"
@click.prevent="toggleExpanded(false)"
>
{{ t('SEARCH.READ_LESS') }}
</button>
</div>
<button
v-if="showReadMore"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium justify-self-end ltr:ml-0.5 rtl:mr-0.5"
@click.prevent="toggleExpanded(true)"
>
{{ t('SEARCH.READ_MORE') }}
</button>
</div>
<blockquote ref="messageContainer" class="message border-l-2 border-n-weak">
<p class="header">
<strong class="text-n-slate-11">
{{ author }}
</strong>
{{ $t('SEARCH.WROTE') }}
</p>
<ReadMore :shrink="isOverflowing" @expand="isOverflowing = false">
<div v-dompurify-html="prepareContent()" class="message-content" />
</ReadMore>
</blockquote>
</template>
<style scoped lang="scss">
.message-content::v-deep p {
@apply inline;
margin: 0;
.message {
@apply py-0 px-2 mt-2;
}
.message-content::v-deep br {
display: none;
.message-content::v-deep p,
.message-content::v-deep li::marker {
@apply text-n-slate-11 mb-1;
}
.header {
@apply text-n-slate-11 mb-1;
}
.message-content {
@apply break-words text-n-slate-11;
}
.message-content::v-deep .searchkey--highlight {
@apply text-n-slate-12 text-sm font-semibold;
}
</style>
@@ -0,0 +1,38 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
shrink: {
type: Boolean,
default: false,
},
});
defineEmits(['expand']);
</script>
<template>
<div>
<div
:class="{
'max-h-[100px] overflow-hidden relative': shrink,
}"
>
<slot />
<div
v-if="shrink"
class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t to-transparent from-n-background flex items-end justify-center pb-2"
>
<NextButton
:label="$t('SEARCH.READ_MORE')"
icon="i-lucide-chevrons-down"
blue
xs
faded
class="backdrop-filter backdrop-blur-[2px]"
@click.prevent="$emit('expand')"
/>
</div>
</div>
</div>
</template>
@@ -1,116 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['selectSearch', 'clearAll']);
const MAX_RECENT_SEARCHES = 3;
const recentSearches = ref([]);
const loadRecentSearches = () => {
const stored = LocalStorage.get(LOCAL_STORAGE_KEYS.RECENT_SEARCHES) || [];
recentSearches.value = Array.isArray(stored) ? stored : [];
};
const saveRecentSearches = () => {
LocalStorage.set(LOCAL_STORAGE_KEYS.RECENT_SEARCHES, recentSearches.value);
};
const addRecentSearch = query => {
if (!query || query.trim().length < 2) return;
const trimmedQuery = query.trim();
const existingIndex = recentSearches.value.findIndex(
search => search.toLowerCase() === trimmedQuery.toLowerCase()
);
if (existingIndex !== -1) {
recentSearches.value.splice(existingIndex, 1);
}
recentSearches.value.unshift(trimmedQuery);
if (recentSearches.value.length > MAX_RECENT_SEARCHES) {
recentSearches.value = recentSearches.value.slice(0, MAX_RECENT_SEARCHES);
}
saveRecentSearches();
};
const clearRecentSearches = () => {
recentSearches.value = [];
LocalStorage.remove(LOCAL_STORAGE_KEYS.RECENT_SEARCHES);
};
const hasRecentSearches = computed(() => recentSearches.value?.length > 0);
const onSelectSearch = query => {
emit('selectSearch', query);
};
const onClearAll = () => {
clearRecentSearches();
emit('clearAll');
};
defineExpose({
addRecentSearch,
loadRecentSearches,
});
onMounted(() => {
loadRecentSearches();
});
</script>
<template>
<div v-if="hasRecentSearches" class="px-4 pb-4 w-full pt-2">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2.5">
<Icon icon="i-lucide-rotate-ccw" class="text-n-slate-10 size-4" />
<h3 class="text-base font-medium text-n-slate-10">
{{ $t('SEARCH.RECENT_SEARCHES') }}
</h3>
</div>
<Button
type="button"
xs
slate
ghost
class="!text-n-slate-10 hover:!text-n-slate-12"
@mousedown.prevent
@click="onClearAll"
>
{{ $t('SEARCH.CLEAR_ALL') }}
</Button>
</div>
<div class="flex flex-col gap-4 items-start">
<button
v-for="(search, index) in recentSearches"
:key="search"
type="button"
class="w-full flex items-center gap-2.5 text-left text-base text-n-slate-12 rounded-lg transition-all duration-150 group p-0"
@mousedown.prevent
@click="onSelectSearch(search)"
>
<Icon
icon="i-lucide-search"
class="text-n-slate-10 group-hover:text-n-slate-11 transition-colors duration-150 size-4"
/>
<span class="flex-1 truncate">{{ search }}</span>
<span
class="text-xs text-n-slate-8 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
{{ index === 0 ? $t('SEARCH.MOST_RECENT') : '' }}
</span>
</button>
</div>
</div>
<template v-else />
</template>
@@ -1,239 +0,0 @@
<script setup>
import { ref, computed, defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { debounce } from '@chatwoot/utils';
import { useMapGetter } from 'dashboard/composables/store.js';
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { fetchContactDetails } from '../helpers/searchHelper';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
label: { type: String, required: true },
});
const emit = defineEmits(['change']);
const FROM_TYPE = {
CONTACT: 'contact',
AGENT: 'agent',
};
const MENU_ACTIONS_SELECT = 'select';
const modelValue = defineModel({ type: String, default: null });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const searchQuery = ref('');
const searchedContacts = ref([]);
const isSearching = ref(false);
const selectedContact = ref(null);
const agentsList = useMapGetter('agents/getVerifiedAgents');
const createMenuItem = (item, type, isAgent = false) => {
const transformed = useCamelCase(item, { deep: true });
const value = `${type}:${transformed.id}`;
return {
label: transformed.name,
value,
action: MENU_ACTIONS_SELECT,
type,
thumbnail: {
name: transformed.name,
src: isAgent ? transformed.avatarUrl : transformed.thumbnail,
},
...(isAgent
? {}
: { description: transformed.email || transformed.phoneNumber }),
isSelected: modelValue.value === value,
};
};
const agentsSection = computed(() => {
const agents =
agentsList.value?.map(agent =>
createMenuItem(agent, FROM_TYPE.AGENT, true)
) || [];
return searchQuery.value
? agents.filter(agent =>
agent.label.toLowerCase().includes(searchQuery.value.toLowerCase())
)
: agents;
});
const contactsSection = computed(
() =>
searchedContacts.value?.map(contact =>
createMenuItem(contact, FROM_TYPE.CONTACT)
) || []
);
const menuSections = computed(() => [
{
title: t('SEARCH.FILTERS.CONTACTS'),
items: contactsSection.value,
isLoading: isSearching.value,
emptyState: t('SEARCH.FILTERS.NO_CONTACTS'),
},
{
title: t('SEARCH.FILTERS.AGENTS'),
items: agentsSection.value,
emptyState: t('SEARCH.FILTERS.NO_AGENTS'),
},
]);
const selectedLabel = computed(() => {
if (!modelValue.value) return props.label;
const [type, id] = modelValue.value.split(':');
const numericId = Number(id);
if (type === FROM_TYPE.CONTACT) {
if (selectedContact.value?.id === numericId) {
return `${props.label}: ${selectedContact.value.name}`;
}
const contact = searchedContacts.value?.find(c => c.id === numericId);
if (contact) return `${props.label}: ${contact.name}`;
} else if (type === FROM_TYPE.AGENT) {
const agent = agentsList.value?.find(a => a.id === numericId);
if (agent) return `${props.label}: ${agent.name}`;
}
return `${props.label}: ${numericId}`;
});
const debouncedSearch = debounce(async query => {
if (!query) {
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
isSearching.value = false;
return;
}
try {
const contacts = await searchContacts({
keys: ['name', 'email', 'phone_number'],
query,
});
// Add selected contact to top if not already in results
const allContacts = selectedContact.value
? [
selectedContact.value,
...contacts.filter(c => c.id !== selectedContact.value.id),
]
: contacts;
searchedContacts.value = allContacts;
} catch {
// Ignore error
} finally {
isSearching.value = false;
}
}, 300);
const performSearch = query => {
searchQuery.value = query;
if (query) {
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
isSearching.value = true;
}
debouncedSearch(query);
};
const onToggleDropdown = () => {
if (!showDropdown.value) {
// Reset search when opening dropdown
searchQuery.value = '';
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
}
toggleDropdown();
};
const handleAction = item => {
if (modelValue.value === item.value) {
modelValue.value = null;
selectedContact.value = null;
} else {
modelValue.value = item.value;
if (item.type === FROM_TYPE.CONTACT) {
const [, id] = item.value.split(':');
selectedContact.value = {
id: Number(id),
name: item.label,
thumbnail: item.thumbnail?.src,
};
} else {
selectedContact.value = null;
}
}
toggleDropdown(false);
emit('change');
};
const resolveContactName = async () => {
if (!modelValue.value) return;
const [type, id] = modelValue.value.split(':');
if (type !== FROM_TYPE.CONTACT) return;
const numericId = Number(id);
if (selectedContact.value?.id === numericId) return;
const contact = await fetchContactDetails(numericId);
if (contact) {
selectedContact.value = {
id: contact.id,
name: contact.name,
thumbnail: contact.thumbnail,
};
if (!searchedContacts.value.some(c => c.id === contact.id)) {
searchedContacts.value.push(selectedContact.value);
}
}
};
watch(() => modelValue.value, resolveContactName, { immediate: true });
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
:variant="showDropdown ? 'faded' : 'ghost'"
slate
:label="selectedLabel"
trailing-icon
icon="i-lucide-chevron-down"
class="!px-2 max-w-full"
@click="onToggleDropdown"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
show-search
disable-local-filtering
:is-searching="isSearching"
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64 max-h-80 overflow-y-auto"
@search="performSearch"
@action="handleAction"
/>
</div>
</template>
@@ -1,271 +0,0 @@
<script setup>
import { computed, ref, defineModel } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import {
subDays,
subMonths,
subYears,
startOfDay,
endOfDay,
format,
getUnixTime,
fromUnixTime,
} from 'date-fns';
import { DATE_RANGE_TYPES } from '../helpers/searchHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['change']);
const modelValue = defineModel({
type: Object,
default: () => ({ type: null, from: null, to: null }),
});
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const customFrom = ref('');
const customTo = ref('');
const rangeType = ref(DATE_RANGE_TYPES.BETWEEN);
// Calculate min date (90 days ago) for date inputs
const minDate = computed(() => format(subDays(new Date(), 90), 'yyyy-MM-dd'));
const maxDate = computed(() => format(new Date(), 'yyyy-MM-dd'));
// Check if both custom date inputs have values
const hasCustomDates = computed(() => customFrom.value && customTo.value);
const DATE_FILTER_ACTIONS = {
PRESET: 'preset',
SELECT: 'select',
};
const PRESET_RANGES = computed(() => [
{
label: t('SEARCH.DATE_RANGE.LAST_7_DAYS'),
value: DATE_RANGE_TYPES.LAST_7_DAYS,
days: 7,
},
{
label: t('SEARCH.DATE_RANGE.LAST_30_DAYS'),
value: DATE_RANGE_TYPES.LAST_30_DAYS,
days: 30,
},
{
label: t('SEARCH.DATE_RANGE.LAST_60_DAYS'),
value: DATE_RANGE_TYPES.LAST_60_DAYS,
days: 60,
},
{
label: t('SEARCH.DATE_RANGE.LAST_90_DAYS'),
value: DATE_RANGE_TYPES.LAST_90_DAYS,
days: 90,
},
]);
const computeDateRange = config => {
const end = endOfDay(new Date());
let start;
if (config.days) {
start = startOfDay(subDays(end, config.days));
} else if (config.months) {
start = startOfDay(subMonths(end, config.months));
} else {
start = startOfDay(subYears(end, config.years));
}
return { type: config.value, from: getUnixTime(start), to: getUnixTime(end) };
};
const selectedValue = computed(() => {
const { from, to, type } = modelValue.value || {};
if (!from && !to && !type) return '';
return type || DATE_RANGE_TYPES.CUSTOM;
});
const menuItems = computed(() =>
PRESET_RANGES.value.map(item => ({
...item,
action: DATE_FILTER_ACTIONS.PRESET,
isSelected: selectedValue.value === item.value,
}))
);
const applySelection = ({ type, from, to }) => {
const newValue = { type, from, to };
modelValue.value = newValue;
emit('change', newValue);
};
const clearFilter = () => {
applySelection({ type: null, from: null, to: null });
customFrom.value = '';
customTo.value = '';
toggleDropdown(false);
};
const handlePresetAction = item => {
if (selectedValue.value === item.value) {
clearFilter();
return;
}
customFrom.value = '';
customTo.value = '';
applySelection(computeDateRange(item));
toggleDropdown(false);
};
const applyCustomRange = () => {
const customFromDate = customFrom.value
? startOfDay(new Date(customFrom.value))
: null;
const customToDate = customTo.value
? endOfDay(new Date(customTo.value))
: null;
// Only BETWEEN mode - require both dates
if (customFromDate && customToDate) {
applySelection({
type: DATE_RANGE_TYPES.BETWEEN,
from: getUnixTime(customFromDate),
to: getUnixTime(customToDate),
});
toggleDropdown(false);
}
};
const clearCustomRange = () => {
customFrom.value = '';
customTo.value = '';
};
const formatDate = timestamp => format(fromUnixTime(timestamp), 'MMM d, yyyy'); // (e.g., "Jan 15, 2024")
const selectedLabel = computed(() => {
const prefix = t('SEARCH.DATE_RANGE.TIME_RANGE');
if (!selectedValue.value) return prefix;
// Check if it's a preset
const preset = PRESET_RANGES.value.find(p => p.value === selectedValue.value);
if (preset) return `${prefix}: ${preset.label}`;
// Custom range - only BETWEEN mode with both dates
const { from, to } = modelValue.value;
if (from && to) return `${prefix}: ${formatDate(from)} - ${formatDate(to)}`;
return `${prefix}: ${t('SEARCH.DATE_RANGE.CUSTOM_RANGE')}`;
});
const CUSTOM_RANGE_TYPES = [DATE_RANGE_TYPES.BETWEEN, DATE_RANGE_TYPES.CUSTOM];
const onToggleDropdown = () => {
if (!showDropdown.value) {
const { type, from, to } = modelValue.value || {};
rangeType.value = CUSTOM_RANGE_TYPES.includes(type)
? type
: DATE_RANGE_TYPES.BETWEEN;
if (CUSTOM_RANGE_TYPES.includes(type)) {
try {
customFrom.value = from ? format(fromUnixTime(from), 'yyyy-MM-dd') : '';
customTo.value = to ? format(fromUnixTime(to), 'yyyy-MM-dd') : '';
} catch {
customFrom.value = '';
customTo.value = '';
}
} else {
customFrom.value = '';
customTo.value = '';
}
}
toggleDropdown();
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
slate
:variant="showDropdown ? 'faded' : 'solid'"
:label="selectedLabel"
class="group-hover:bg-n-alpha-2 max-w-full"
trailing-icon
icon="i-lucide-chevron-down"
@click="onToggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64"
@action="handlePresetAction"
>
<template #footer>
<div class="h-px bg-n-strong" />
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2 px-1 h-9">
<span class="text-sm text-n-slate-11">
{{ t('SEARCH.DATE_RANGE.CUSTOM_RANGE') }}
</span>
<span class="text-sm text-n-slate-12">
{{ t('SEARCH.DATE_RANGE.CREATED_BETWEEN') }}
</span>
</div>
<input
v-model="customFrom"
type="date"
:min="minDate"
:max="customTo || maxDate"
class="!w-full !mb-0 !rounded-lg !bg-n-alpha-black2 !outline-n-strong -outline-offset-1 !px-3 !py-2 !text-sm text-n-slate-12 !h-8"
/>
<div class="flex items-center gap-3 h-5 px-1">
<div class="flex-1 h-px bg-n-weak" />
<span class="text-sm text-n-slate-11">
{{ t('SEARCH.DATE_RANGE.AND') }}
</span>
<div class="flex-1 h-px bg-n-weak" />
</div>
<input
v-model="customTo"
type="date"
:min="customFrom || minDate"
:max="maxDate"
class="!w-full !mb-0 !rounded-lg !bg-n-alpha-black2 !outline-n-strong -outline-offset-1 !px-3 !py-2 !text-sm text-n-slate-12 !h-8"
/>
<div class="flex items-center gap-2 mt-2">
<Button
sm
slate
faded
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
:disabled="!hasCustomDates"
class="flex-1 justify-center"
@click="clearCustomRange"
/>
<Button
sm
solid
color="blue"
:label="t('SEARCH.DATE_RANGE.APPLY')"
:disabled="!hasCustomDates"
class="flex-1 justify-center"
@click="applyCustomRange"
/>
</div>
</div>
</template>
</DropdownMenu>
</div>
</template>
@@ -1,104 +0,0 @@
<script setup>
import { computed, defineModel } from 'vue';
import { useI18n } from 'vue-i18n';
import SearchDateRangeSelector from './SearchDateRangeSelector.vue';
import SearchContactAgentSelector from './SearchContactAgentSelector.vue';
import SearchInboxSelector from './SearchInboxSelector.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['updateFilters']);
const { t } = useI18n();
const filters = defineModel({
type: Object,
default: () => ({
from: null, // Contact id and Agent id
in: null, // Inbox id
dateRange: { type: null, from: null, to: null },
}),
});
const hasActiveFilters = computed(
() =>
filters.value.from ||
filters.value.in ||
filters.value.dateRange?.type ||
filters.value.dateRange?.from ||
filters.value.dateRange?.to
);
const onFilterChange = () => {
emit('updateFilters', filters.value);
};
const clearAllFilters = () => {
filters.value = {
from: null,
in: null,
dateRange: { type: null, from: null, to: null },
};
onFilterChange();
};
</script>
<template>
<div
class="flex flex-col lg:flex-row items-start lg:items-center gap-3 p-4 w-full min-w-0"
>
<div class="flex items-center gap-3 min-w-0 max-w-full">
<Button
v-if="hasActiveFilters"
sm
slate
solid
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
icon="i-lucide-x"
class="flex-shrink-0 lg:hidden"
@click="clearAllFilters"
/>
<SearchDateRangeSelector
v-model="filters.dateRange"
class="min-w-0 max-w-full"
@change="onFilterChange"
/>
</div>
<div class="w-px h-4 bg-n-weak flex-shrink-0 hidden lg:block" />
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-full">
<span class="text-sm text-n-slate-10 flex-shrink-0 whitespace-nowrap">
{{ t('SEARCH.FILTERS.FILTER_MESSAGE') }}
</span>
<div class="min-w-0">
<SearchContactAgentSelector
v-model="filters.from"
:label="$t('SEARCH.FILTERS.FROM')"
@change="onFilterChange"
/>
</div>
<div class="w-px h-3 bg-n-weak rounded-lg flex-shrink-0" />
<div class="min-w-0">
<SearchInboxSelector
v-model="filters.in"
:label="$t('SEARCH.FILTERS.IN')"
@change="onFilterChange"
/>
</div>
</div>
<Button
v-if="hasActiveFilters"
sm
slate
solid
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
icon="i-lucide-x"
class="flex-shrink-0 hidden lg:inline-flex"
@click="clearAllFilters"
/>
</div>
</template>
@@ -1,34 +1,48 @@
<script setup>
import { ref, watch, useTemplateRef, defineModel } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { ROLES } from 'dashboard/constants/permissions';
import SearchInput from './SearchInput.vue';
import SearchFilters from './SearchFilters.vue';
import Policy from 'dashboard/components/policy.vue';
import { ref, useTemplateRef, onMounted, onUnmounted, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
const props = defineProps({
initialQuery: { type: String, default: '' },
initialQuery: {
type: String,
default: '',
},
});
const emit = defineEmits(['search', 'filterChange']);
const emit = defineEmits(['search']);
const filters = defineModel('filters', { type: Object, default: () => ({}) });
const searchInputRef = useTemplateRef('searchInputRef');
const searchQuery = ref(props.initialQuery);
const isInputFocused = ref(false);
const onSearch = query => {
if (query?.trim() && searchInputRef.value) {
searchInputRef.value.addToRecentSearches(query.trim());
const searchInput = useTemplateRef('searchInput');
const handler = e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchInput.value.focus();
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
e.preventDefault();
searchInput.value.blur();
}
emit('search', query);
};
const onSelectRecentSearch = query => {
searchQuery.value = query;
onSearch(query);
const debouncedEmit = debounce(
value =>
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
500
);
const onInput = e => {
searchQuery.value = e.target.value;
debouncedEmit(searchQuery.value);
};
const onFocus = () => {
isInputFocused.value = true;
};
const onBlur = () => {
isInputFocused.value = false;
};
watch(
@@ -40,30 +54,51 @@ watch(
},
{ immediate: true }
);
onMounted(() => {
searchInput.value.focus();
document.addEventListener('keydown', handler);
});
onUnmounted(() => {
document.removeEventListener('keydown', handler);
});
</script>
<template>
<div class="flex flex-col gap-2">
<SearchInput
ref="searchInputRef"
v-model="searchQuery"
@search="onSearch"
@select-recent-search="onSelectRecentSearch"
>
<Policy
:permissions="ROLES"
:installation-types="[
INSTALLATION_TYPES.ENTERPRISE,
INSTALLATION_TYPES.CLOUD,
]"
:feature-flag="FEATURE_FLAGS.ADVANCED_SEARCH"
class="w-full"
>
<SearchFilters
v-model="filters"
@update-filters="$emit('filterChange', $event)"
/>
</Policy>
</SearchInput>
<div
class="input-container rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-center py-2 px-4 h-14 gap-2 border border-solid bg-n-alpha-black2"
:class="{
'border-n-brand': isInputFocused,
'border-n-weak': !isInputFocused,
}"
>
<div class="flex items-center">
<fluent-icon
icon="search"
class="icon"
aria-hidden="true"
:class="{
'text-n-blue-text': isInputFocused,
'text-n-slate-10': !isInputFocused,
}"
/>
</div>
<input
ref="searchInput"
type="search"
class="reset-base outline-none w-full m-0 bg-transparent border-transparent shadow-none text-n-slate-12 dark:text-n-slate-12 active:border-transparent active:shadow-none hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none"
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
<woot-label
:title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"
:show-close="false"
small
class="!m-0 whitespace-nowrap !bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong"
/>
</div>
</template>
@@ -1,125 +0,0 @@
<script setup>
import { ref, computed, defineModel } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
label: {
type: String,
required: true,
},
});
const emit = defineEmits(['change']);
const modelValue = defineModel({
type: [String, Number],
default: null,
});
const MENU_ITEM_TYPES = {
INBOX: 'inbox',
};
const MENU_ACTIONS = {
SELECT: 'select',
};
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const searchQuery = ref('');
const inboxesList = useMapGetter('inboxes/getInboxes');
const inboxesSection = computed(() => {
const inboxes = inboxesList.value?.map(inbox => {
const transformedInbox = useCamelCase(inbox, { deep: true });
return {
label: transformedInbox.name,
value: transformedInbox.id,
action: MENU_ACTIONS.SELECT,
type: MENU_ITEM_TYPES.INBOX,
thumbnail: {
name: transformedInbox.name,
src: transformedInbox.avatarUrl,
},
isSelected: modelValue.value === transformedInbox.id,
};
});
if (!searchQuery.value) return inboxes;
return inboxes.filter(inbox =>
inbox.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const menuSections = computed(() => {
return [
{
title: t('SEARCH.FILTERS.INBOXES'),
items: inboxesSection.value,
emptyState: t('SEARCH.FILTERS.NO_INBOXES'),
},
];
});
const selectedLabel = computed(() => {
if (!modelValue.value) return props.label;
// Find the selected inbox
const inbox = inboxesList.value?.find(i => i.id === modelValue.value);
if (inbox) return `${props.label}: ${inbox.name}`;
return `${props.label}: ${modelValue.value}`;
});
const handleAction = item => {
if (modelValue.value === item.value) {
modelValue.value = null;
} else {
modelValue.value = item.value;
}
toggleDropdown(false);
emit('change');
};
const onToggleDropdown = () => {
if (!showDropdown.value) {
searchQuery.value = '';
}
toggleDropdown();
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
:variant="showDropdown ? 'faded' : 'ghost'"
slate
:label="selectedLabel"
trailing-icon
icon="i-lucide-chevron-down"
class="!px-2 max-w-full"
@click="onToggleDropdown"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
show-search
disable-local-filtering
class="mt-1 ltr:right-0 rtl:left-0 top-full w-64 max-h-80 overflow-y-auto"
@search="searchQuery = $event"
@action="handleAction"
/>
</div>
</template>
@@ -1,136 +0,0 @@
<script setup>
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { debounce } from '@chatwoot/utils';
import RecentSearches from './RecentSearches.vue';
const emit = defineEmits(['search', 'selectRecentSearch']);
const searchQuery = defineModel({
type: String,
default: '',
});
const isInputFocused = ref(false);
const showRecentSearches = ref(false);
const searchInput = useTemplateRef('searchInput');
const recentSearchesRef = useTemplateRef('recentSearchesRef');
const handler = e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchInput.value.focus();
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
e.preventDefault();
searchInput.value.blur();
}
};
const debouncedEmit = debounce(
value =>
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
500
);
const onInput = () => {
debouncedEmit(searchQuery.value);
if (searchQuery.value.trim()) {
showRecentSearches.value = false;
} else if (isInputFocused.value) {
showRecentSearches.value = true;
}
};
const onFocus = () => {
isInputFocused.value = true;
if (!searchQuery.value.trim()) {
showRecentSearches.value = true;
}
};
const onBlur = () => {
isInputFocused.value = false;
showRecentSearches.value = false;
};
const onSelectRecentSearch = query => {
searchQuery.value = query;
emit('selectRecentSearch', query);
showRecentSearches.value = false;
searchInput.value.focus();
};
const addToRecentSearches = query => {
if (recentSearchesRef.value) {
recentSearchesRef.value.addRecentSearch(query);
}
};
defineExpose({
addToRecentSearches,
});
onMounted(() => {
searchInput.value.focus();
document.addEventListener('keydown', handler);
});
onUnmounted(() => {
document.removeEventListener('keydown', handler);
});
</script>
<template>
<div
class="rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-start flex-col border border-solid bg-n-solid-1 divide-y divide-n-strong"
:class="{
'border-n-brand': isInputFocused,
'border-n-strong': !isInputFocused,
}"
>
<div class="flex items-center w-full h-[3.25rem] px-4 gap-2">
<div class="flex items-center">
<fluent-icon
icon="search"
class="icon"
aria-hidden="true"
:class="{
'text-n-blue-text': isInputFocused,
'text-n-slate-10': !isInputFocused,
}"
/>
</div>
<input
ref="searchInput"
v-model="searchQuery"
type="search"
class="reset-base outline-none w-full m-0 bg-transparent border-transparent shadow-none text-n-slate-12 dark:text-n-slate-12 active:border-transparent active:shadow-none hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none placeholder:text-n-slate-10 text-base"
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
<span class="text-sm text-n-slate-10 flex-shrink-0">
{{ $t('SEARCH.PLACEHOLDER_KEYBINDING') }}
</span>
</div>
<slot />
<div
class="transition-all duration-200 ease-out grid overflow-hidden w-full !border-t-0"
:class="
showRecentSearches
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden w-full">
<RecentSearches
ref="recentSearchesRef"
@select-search="onSelectRecentSearch"
@clear-all="showRecentSearches = false"
/>
</div>
</div>
</div>
</template>
@@ -1,10 +1,7 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { ARTICLE_STATUSES } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter';
const props = defineProps({
@@ -16,8 +13,6 @@ const props = defineProps({
content: { type: String, default: '' },
portalSlug: { type: String, required: true },
accountId: { type: [String, Number], default: 0 },
status: { type: String, default: '' },
updatedAt: { type: Number, default: 0 },
});
const MAX_LENGTH = 300;
@@ -28,11 +23,6 @@ const navigateTo = computed(() => {
);
});
const updatedAtTime = computed(() => {
if (!props.updatedAt) return '';
return dynamicTime(props.updatedAt);
});
const truncatedContent = computed(() => {
if (!props.content) return props.description || '';
@@ -44,62 +34,36 @@ const truncatedContent = computed(() => {
? `${plainText.substring(0, MAX_LENGTH)}...`
: plainText;
});
const statusTextColor = computed(() => {
switch (props.status) {
case ARTICLE_STATUSES.ARCHIVED:
return 'text-n-slate-12';
case ARTICLE_STATUSES.DRAFT:
return 'text-n-amber-11';
default:
return 'text-n-teal-11';
}
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:pt-4 [&>div]:pb-5 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
<router-link
:to="navigateTo"
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<div
class="flex items-center justify-center w-6 h-6 mt-0.5 rounded bg-n-slate-3"
>
<div class="min-w-0 flex-1 flex flex-col items-start gap-2 w-full">
<div class="flex items-center min-w-0 justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<h5
class="text-sm font-medium leading-4 truncate min-w-0 text-n-slate-12"
>
{{ title }}
</h5>
<div v-if="category" class="w-px h-4 bg-n-strong mx-2" />
<span
v-if="category"
class="text-xs inline-flex items-center font-medium rounded-md whitespace-nowrap capitalize bg-n-alpha-2 px-1.5 h-6 text-n-slate-12"
>
{{ category }}
</span>
<span
v-if="status"
class="text-xs inline-flex items-center font-medium rounded-md whitespace-nowrap capitalize bg-n-alpha-2 px-2 h-6"
:class="statusTextColor"
>
{{ status }}
</span>
</div>
<span
v-if="updatedAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ updatedAtTime }}
</span>
</div>
<p
v-if="truncatedContent"
class="text-sm leading-6 text-n-slate-11 line-clamp-2"
<Icon icon="i-lucide-library-big" class="text-n-slate-10" />
</div>
<div class="ltr:ml-2 rtl:mr-2 min-w-0 flex-1">
<div class="flex items-center gap-2">
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12">
{{ title }}
</h5>
<span
v-if="category"
class="text-xs font-medium whitespace-nowrap capitalize bg-n-slate-3 px-1 py-0.5 rounded text-n-slate-10"
>
{{ truncatedContent }}
</p>
{{ category }}
</span>
</div>
</CardLayout>
<p
v-if="truncatedContent"
class="mt-1 text-sm text-n-slate-11 line-clamp-2"
>
{{ truncatedContent }}
</p>
</div>
</router-link>
</template>
@@ -34,19 +34,18 @@ const accountId = useMapGetter('getCurrentAccountId');
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="articles.length" class="space-y-3 list-none">
<ul v-if="articles.length" class="space-y-1.5 list-none">
<li v-for="article in articles" :key="article.id">
<SearchResultArticleItem
:id="article.id"
:title="article.title"
:description="article.description"
:content="article.content"
:portal-slug="article.portalSlug"
:portal-slug="article.portal_slug"
:locale="article.locale"
:account-id="accountId"
:category="article.categoryName"
:category="article.category_name"
:status="article.status"
:updated-at="article.updatedAt"
/>
</li>
</ul>
@@ -1,12 +1,8 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import countries from 'shared/constants/countries';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
const props = defineProps({
id: {
@@ -33,122 +29,38 @@ const props = defineProps({
type: [String, Number],
default: 0,
},
additionalAttributes: {
type: Object,
default: () => ({}),
},
updatedAt: {
type: Number,
default: 0,
},
});
const navigateTo = computed(() => {
return frontendURL(`accounts/${props.accountId}/contacts/${props.id}`);
});
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
});
const updatedAtTime = computed(() => {
if (!props.updatedAt) return '';
return dynamicTime(props.updatedAt);
});
const countryDetails = computed(() => {
const { country, countryCode, city } = props.additionalAttributes;
if (!country && !countryCode) return null;
const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null;
return {
countryCode: activeCountry.id,
city: city ? `${city},` : null,
name: activeCountry.name,
};
});
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
return [countryDetails.value.city, countryDetails.value.name]
.filter(Boolean)
.join(' ');
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="row"
class="[&>div]:justify-start [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<Avatar
:name="name"
:src="thumbnail"
:size="24"
rounded-full
class="mt-1 flex-shrink-0"
/>
<div class="min-w-0 flex flex-col items-start gap-1.5 w-full">
<div class="flex items-center min-w-0 justify-between gap-2 w-full">
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12 py-1">
{{ name }}
</h5>
<span
v-if="updatedAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ $t('SEARCH.UPDATED_AT', { time: updatedAtTime }) }}
</span>
</div>
<div
class="grid items-center gap-3 m-0 text-sm overflow-hidden min-w-0 grid-cols-[minmax(0,max-content)_auto_minmax(0,max-content)_auto_minmax(0,max-content)]"
>
<span
v-if="email"
class="truncate text-n-slate-11 min-w-0"
:title="email"
>
{{ email }}
</span>
<div v-if="email && phone" class="w-px h-3 bg-n-slate-6 rounded" />
<span
v-if="phone"
:title="phone"
class="truncate text-n-slate-11 min-w-0"
>
{{ phone }}
</span>
<div
v-if="(email || phone) && countryDetails"
class="w-px h-3 bg-n-slate-6 rounded"
/>
<span
v-if="countryDetails"
class="truncate text-n-slate-11 flex items-center gap-1 min-w-0"
>
<Flag
:country="countryDetails.countryCode"
class="size-3 shrink-0"
/>
<span class="truncate min-w-0">{{ formattedLocation }}</span>
</span>
</div>
</div>
</CardLayout>
<router-link
:to="navigateTo"
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<Avatar
:name="name"
:src="thumbnail"
:size="24"
rounded-full
class="mt-0.5"
/>
<div class="ml-2 rtl:mr-2 min-w-0 rtl:ml-0">
<h5 class="text-sm name truncate min-w-0 text-n-slate-12">
{{ name }}
</h5>
<p
class="grid items-center m-0 gap-1 text-sm grid-cols-[minmax(0,1fr)_auto_auto]"
>
<span v-if="email" class="truncate text-n-slate-12" :title="email">
{{ email }}
</span>
<span v-if="phone" class="text-n-slate-10"></span>
<span v-if="phone" class="text-n-slate-12">{{ phone }}</span>
</p>
</div>
</router-link>
</template>
@@ -34,17 +34,15 @@ const accountId = useMapGetter('getCurrentAccountId');
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="contacts.length" class="space-y-3 list-none">
<ul v-if="contacts.length" class="space-y-1.5 list-none">
<li v-for="contact in contacts" :key="contact.id">
<SearchResultContactItem
:id="contact.id"
:name="contact.name"
:email="contact.email"
:phone="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:phone="contact.phone_number"
:account-id="accountId"
:thumbnail="contact.thumbnail"
:updated-at="contact.lastActivityAt"
/>
</li>
</ul>
@@ -1,12 +1,10 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useInbox } from 'dashboard/composables/useInbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: {
@@ -43,8 +41,6 @@ const props = defineProps({
},
});
const { inbox } = useInbox(props.inbox?.id);
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
@@ -56,10 +52,7 @@ const navigateTo = computed(() => {
);
});
const createdAtTime = computed(() => {
if (!props.createdAt) return '';
return dynamicTime(props.createdAt);
});
const createdAtTime = dynamicTime(props.createdAt);
const infoItems = computed(() => [
{
@@ -82,76 +75,58 @@ const infoItems = computed(() => [
const visibleInfoItems = computed(() =>
infoItems.value.filter(item => item.show)
);
const inboxName = computed(() => props.inbox?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<div
class="flex items-center min-w-0 justify-between gap-2 w-full h-7 mb-1"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon
icon="i-lucide-hash"
class="flex-shrink-0 text-n-slate-11 size-4"
<router-link
:to="navigateTo"
class="flex p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<Avatar
name="chats"
:size="24"
icon-name="i-lucide-messages-square"
class="[&>span]:rounded"
/>
<div class="flex-grow min-w-0 ml-2">
<div class="flex items-center min-w-0 justify-between gap-1 mb-1">
<div class="flex">
<woot-label
class="!bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong m-0"
:title="`#${id}`"
:show-close="false"
small
/>
<div
class="flex items-center justify-center h-5 ml-1 rounded bg-n-slate-3 dark:bg-n-solid-3 w-fit rtl:ml-0 rtl:mr-1"
>
<InboxName
:inbox="inbox"
class="mx-2 bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-11 dark:text-n-slate-11"
/>
<span class="text-n-slate-12 text-sm leading-4">
{{ id }}
</span>
</div>
<div v-if="inboxName" class="w-px h-3 bg-n-strong" />
<div v-if="inboxName" class="flex items-center gap-1.5 flex-shrink-0">
<div
v-if="inboxIcon"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm leading-4 text-n-slate-12">
{{ inboxName }}
</span>
</div>
</div>
<span
v-if="createdAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
class="text-xs font-normal min-w-0 truncate text-n-slate-11 dark:text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<div class="flex flex-wrap gap-x-2 gap-y-1.5 items-center">
<template
v-for="(item, index) in visibleInfoItems"
:key="`info-${index}`"
<div class="flex flex-wrap gap-x-2 gap-y-1.5">
<h5
v-for="item in visibleInfoItems"
:key="item.label"
class="m-0 text-sm min-w-0 text-n-slate-12 dark:text-n-slate-12 truncate"
>
<h5 class="m-0 text-sm min-w-0 text-n-slate-12 truncate">
<span class="text-sm leading-4 font-normal text-n-slate-11">
{{ $t(item.label) + ':' }}
</span>
{{ item.value }}
</h5>
<div
v-if="index < visibleInfoItems.length - 1"
class="w-px h-3 bg-n-strong"
/>
</template>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
{{ $t(item.label) }}:
</span>
{{ item.value }}
</h5>
</div>
<slot />
</CardLayout>
</div>
</router-link>
</template>
@@ -1,7 +1,6 @@
<script setup>
import { defineProps, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
@@ -29,7 +28,7 @@ const accountId = useMapGetter('getCurrentAccountId');
const conversationsWithSubject = computed(() => {
return props.conversations.map(conversation => ({
...conversation,
mailSubject: conversation.additionalAttributes?.mailSubject || '',
mail_subject: conversation.additional_attributes?.mail_subject || '',
}));
});
</script>
@@ -42,7 +41,7 @@ const conversationsWithSubject = computed(() => {
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="conversations.length" class="space-y-3 list-none">
<ul v-if="conversations.length" class="space-y-1.5 list-none">
<li
v-for="conversation in conversationsWithSubject"
:key="conversation.id"
@@ -53,8 +52,8 @@ const conversationsWithSubject = computed(() => {
:email="conversation.contact.email"
:account-id="accountId"
:inbox="conversation.inbox"
:created-at="conversation.createdAt"
:email-subject="conversation.mailSubject"
:created-at="conversation.created_at"
:email-subject="conversation.mail_subject"
/>
</li>
</ul>
@@ -1,169 +0,0 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useInbox } from 'dashboard/composables/useInbox';
import { ATTACHMENT_TYPES } from 'dashboard/components-next/message/constants.js';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import FileChip from 'next/message/chips/File.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import TranscribedText from './TranscribedText.vue';
const props = defineProps({
id: {
type: Number,
default: 0,
},
inboxId: {
type: Number,
default: 0,
},
isPrivate: {
type: Boolean,
default: false,
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
attachments: {
type: Array,
default: () => [],
},
});
const { inbox } = useInbox(props.inboxId);
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
params.messageId = props.messageId;
}
return frontendURL(
`accounts/${props.accountId}/conversations/${props.id}`,
params
);
});
const createdAtTime = computed(() => {
if (!props.createdAt) return '';
return dynamicTime(props.createdAt);
});
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const fileAttachments = computed(() => {
return props.attachments.filter(
attachment => attachment.fileType !== ATTACHMENT_TYPES.AUDIO
);
});
const audioAttachments = computed(() => {
return props.attachments.filter(
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
);
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<div
class="flex items-center min-w-0 justify-between gap-2 w-full h-7 mb-1"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon
icon="i-lucide-hash"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<span class="text-n-slate-12 text-sm leading-4">
{{ id }}
</span>
</div>
<div v-if="inboxName" class="w-px h-3 bg-n-strong" />
<div v-if="inboxName" class="flex items-center gap-1.5 flex-shrink-0">
<div
v-if="inboxIcon"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm leading-4 text-n-slate-12">
{{ inboxName }}
</span>
</div>
<div v-if="isPrivate" class="w-px h-3 bg-n-strong" />
<div
v-if="isPrivate"
class="flex items-center text-n-amber-11 gap-1.5 flex-shrink-0"
>
<Icon icon="i-lucide-lock-keyhole" class="flex-shrink-0 size-3.5" />
<span class="text-sm leading-4">
{{ $t('SEARCH.PRIVATE') }}
</span>
</div>
</div>
<span
v-if="createdAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<slot />
<div v-if="audioAttachments.length" class="mt-1.5 space-y-4 w-full">
<div
v-for="attachment in audioAttachments"
:key="attachment.id"
class="w-full"
>
<AudioChip
class="bg-n-alpha-2 dark:bg-n-alpha-2 text-n-slate-12"
:attachment="attachment"
:show-transcribed-text="false"
@click.prevent
/>
<div v-if="attachment.transcribedText" class="pt-2">
<TranscribedText :text="attachment.transcribedText" />
</div>
</div>
</div>
<div
v-if="fileAttachments.length"
class="flex gap-2 flex-wrap items-center mt-1.5"
>
<FileChip
v-for="attachment in fileAttachments"
:key="attachment.id"
:attachment="attachment"
class="!h-8"
@click.stop
/>
</div>
</CardLayout>
</router-link>
</template>
@@ -2,7 +2,7 @@
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultMessageItem from './SearchResultMessageItem.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
import SearchResultSection from './SearchResultSection.vue';
import MessageContent from './MessageContent.vue';
@@ -43,23 +43,21 @@ const getName = message => {
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="messages.length" class="space-y-3 list-none">
<ul v-if="messages.length" class="space-y-1.5 list-none">
<li v-for="message in messages" :key="message.id">
<SearchResultMessageItem
:id="message.conversationId"
<SearchResultConversationItem
:id="message.conversation_id"
:account-id="accountId"
:inbox-id="message.inboxId"
:created-at="message.createdAt"
:inbox="message.inbox"
:created-at="message.created_at"
:message-id="message.id"
:is-private="message.private"
:attachments="message.attachments"
>
<MessageContent
:author="getName(message)"
:message="message"
:search-term="query"
/>
</SearchResultMessageItem>
</SearchResultConversationItem>
</li>
</ul>
</SearchResultSection>
@@ -1,8 +1,6 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
title: {
type: String,
@@ -30,12 +28,9 @@ const titleCase = computed(() => props.title.toLowerCase());
</script>
<template>
<section class="mx-0 mb-3">
<div
v-if="showTitle"
class="sticky top-0 pt-2 py-3 z-50 bg-gradient-to-b from-n-background from-80% to-transparent mb-3 -mx-1.5 px-1.5"
>
<h3 class="text-sm text-n-slate-11">{{ title }}</h3>
<section class="mx-0 my-2">
<div v-if="showTitle" class="sticky top-0 p-2 z-50 mb-0.5 bg-n-background">
<h3 class="text-sm text-n-slate-12">{{ title }}</h3>
</div>
<slot />
<woot-loading-state
@@ -44,12 +39,9 @@ const titleCase = computed(() => props.title.toLowerCase());
/>
<div
v-if="empty && !isFetching"
class="flex items-start justify-center px-4 py-6 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
class="flex items-center justify-center px-4 py-6 m-2 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
>
<Icon
icon="i-lucide-info"
class="text-n-slate-11 size-4 flex-shrink-0 mt-[3px]"
/>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="mx-2 my-0 text-center text-n-slate-11">
{{ $t('SEARCH.EMPTY_STATE', { item: titleCase, query }) }}
</p>
@@ -1,8 +1,5 @@
<script setup>
import { computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { ref, watch } from 'vue';
const props = defineProps({
tabs: {
@@ -17,8 +14,6 @@ const props = defineProps({
const emit = defineEmits(['tabChange']);
const { t } = useI18n();
const activeTab = ref(props.selectedTab);
watch(
@@ -30,35 +25,24 @@ watch(
}
);
const tabBarTabs = computed(() => {
return props.tabs.map(tab => ({
label: tab.name,
count: tab.showBadge ? tab.count : null,
}));
});
const onTabChange = selectedTab => {
const index = props.tabs.findIndex(tab => tab.name === selectedTab.label);
const onTabChange = index => {
activeTab.value = index;
emit('tabChange', props.tabs[index].key);
};
</script>
<template>
<div class="flex items-center justify-between mt-7 mb-4">
<TabBar
:tabs="tabBarTabs"
:initial-active-tab="activeTab"
@tab-changed="onTabChange"
/>
<Button
:label="t('SEARCH.SORT_BY.RELEVANCE')"
sm
link
slate
class="hover:!no-underline pointer-events-none lg:inline-flex hidden"
icon="i-lucide-arrow-up-down"
/>
<div class="mt-1 border-b border-n-weak">
<woot-tabs :index="activeTab" :border="false" @change="onTabChange">
<woot-tabs-item
v-for="(item, index) in tabs"
:key="item.key"
:index="index"
:name="item.name"
:count="item.count"
:show-badge="item.showBadge"
is-compact
/>
</woot-tabs>
</div>
</template>
@@ -4,8 +4,6 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter, useRoute } from 'vue-router';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -49,7 +47,7 @@ const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
const addTypeToRecords = (records, type) =>
records.value.map(item => ({ ...useCamelCase(item, { deep: true }), type }));
records.value.map(item => ({ ...item, type }));
const mappedContacts = computed(() =>
addTypeToRecords(contactRecords, 'contact')
@@ -66,11 +64,6 @@ const mappedArticles = computed(() =>
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
const searchResultSectionClass = computed(() => ({
'mt-4': isSelectedTabAll.value,
'mt-0.5': !isSelectedTabAll.value,
}));
const sliceRecordsIfAllTab = items =>
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
@@ -234,55 +227,30 @@ const showViewMore = computed(() => ({
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
}));
const filters = ref({
from: null,
in: null,
dateRange: { type: null, from: null, to: null },
});
const clearSearchResult = () => {
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
store.dispatch('conversationSearch/clearSearchResults');
};
const buildSearchPayload = (basePayload = {}, searchType = 'message') => {
const payload = { ...basePayload };
const updateURL = () => {
// Update route with tab as URL parameter and query as query parameter
const params = { accountId: route.params.accountId };
const queryParams = {};
// Only include filters if advanced search is enabled
if (isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)) {
// Date filters apply to all search types
if (filters.value.dateRange.from) {
payload.since = filters.value.dateRange.from;
}
if (filters.value.dateRange.to) {
payload.until = filters.value.dateRange.to;
}
// Only messages support 'from' and 'inboxId' filters
if (searchType === 'message') {
if (filters.value.from) payload.from = filters.value.from;
if (filters.value.in) payload.inboxId = filters.value.in;
}
// Only add tab param if not 'all'
if (selectedTab.value !== 'all') {
params.tab = selectedTab.value;
}
return payload;
};
if (query.value?.trim()) {
queryParams.q = query.value.trim();
}
const updateURL = () => {
const params = {
accountId: route.params.accountId,
...(selectedTab.value !== 'all' && { tab: selectedTab.value }),
};
const queryParams = {
...(query.value?.trim() && { q: query.value.trim() }),
...generateURLParams(
filters.value,
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
),
};
router.replace({ name: 'search', params, query: queryParams });
router.replace({
name: 'search',
params,
query: queryParams,
});
};
const onSearch = q => {
@@ -291,13 +259,7 @@ const onSearch = q => {
updateURL();
if (!q) return;
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
const searchPayload = buildSearchPayload({ q, page: 1 });
store.dispatch('conversationSearch/fullSearch', searchPayload);
};
const onFilterChange = () => {
onSearch(query.value);
store.dispatch('conversationSearch/fullSearch', { q, page: 1 });
};
const onBack = () => {
@@ -318,16 +280,12 @@ const loadMore = () => {
};
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
const tab = selectedTab.value;
pages.value[tab] += 1;
const payload = buildSearchPayload(
{ q: query.value, page: pages.value[tab] },
tab
);
store.dispatch(SEARCH_ACTIONS[tab], payload);
store.dispatch(SEARCH_ACTIONS[tab], {
q: query.value,
page: pages.value[tab],
});
};
const onTabChange = tab => {
@@ -337,13 +295,6 @@ const onTabChange = tab => {
onMounted(() => {
store.dispatch('conversationSearch/clearSearchResults');
store.dispatch('agents/get');
const parsedFilters = parseURLParams(
route.query,
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
);
filters.value = parsedFilters;
// Auto-execute search if query parameter exists
if (route.query.q) {
@@ -370,14 +321,9 @@ onUnmounted(() => {
/>
</div>
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-5xl mx-auto z-[60]">
<div class="w-full max-w-4xl mx-auto">
<div class="flex flex-col w-full px-4">
<SearchHeader
v-model:filters="filters"
:initial-query="query"
@search="onSearch"
@filter-change="onFilterChange"
/>
<SearchHeader :initial-query="query" @search="onSearch" />
<SearchTabs
v-if="query"
:tabs="tabs"
@@ -387,7 +333,7 @@ onUnmounted(() => {
</div>
</div>
<div class="flex-grow w-full h-full overflow-y-auto">
<div class="w-full max-w-5xl mx-auto px-4 pb-6">
<div class="w-full max-w-4xl mx-auto px-4 pb-6">
<div v-if="showResultsSection">
<Policy
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
@@ -399,7 +345,6 @@ onUnmounted(() => {
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
class="mt-0.5"
/>
<NextButton
v-if="showViewMore.contacts"
@@ -422,7 +367,6 @@ onUnmounted(() => {
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.messages"
@@ -445,7 +389,6 @@ onUnmounted(() => {
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.conversations"
@@ -470,7 +413,6 @@ onUnmounted(() => {
:articles="articles"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.articles"
@@ -483,7 +425,7 @@ onUnmounted(() => {
/>
</Policy>
<div v-if="showLoadMore" class="flex justify-center mt-3 mb-6">
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
<NextButton
v-if="!isSelectedTabAll"
:label="t(`SEARCH.LOAD_MORE`)"
@@ -1,45 +0,0 @@
<script setup>
import { useExpandableContent } from 'shared/composables/useExpandableContent';
defineProps({
text: {
type: String,
default: '',
},
});
const { contentElement, showReadMore, showReadLess, toggleExpanded } =
useExpandableContent({ useResizeObserverForCheck: true });
</script>
<template>
<span class="py-2 text-xs font-medium">
{{ $t('SEARCH.TRANSCRIPT') }}
</span>
<div
class="text-n-slate-11 pt-1 text-sm rounded-lg w-full break-words grid items-center"
:class="showReadMore ? 'grid-cols-[1fr_auto]' : 'grid-cols-1'"
>
<div
ref="contentElement"
class="min-w-0"
:class="{ 'overflow-hidden line-clamp-1': showReadMore }"
>
{{ text }}
<button
v-if="showReadLess"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium ltr:ml-0.5 rtl:mr-0.5"
@click.prevent.stop="toggleExpanded(false)"
>
{{ $t('SEARCH.READ_LESS') }}
</button>
</div>
<button
v-if="showReadMore"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium justify-self-end ltr:ml-0.5 rtl:mr-0.5"
@click.prevent.stop="toggleExpanded(true)"
>
{{ $t('SEARCH.READ_MORE') }}
</button>
</div>
</template>
@@ -1,75 +0,0 @@
import ContactAPI from 'dashboard/api/contacts';
export const DATE_RANGE_TYPES = {
LAST_7_DAYS: 'last_7_days',
LAST_30_DAYS: 'last_30_days',
LAST_60_DAYS: 'last_60_days',
LAST_90_DAYS: 'last_90_days',
CUSTOM: 'custom',
BETWEEN: 'between',
};
export const generateURLParams = (
{ from, in: inboxId, dateRange },
isAdvancedSearchEnabled = true
) => {
const params = {};
// Only include filter params if advanced search is enabled
if (isAdvancedSearchEnabled) {
if (from) params.from = from;
if (inboxId) params.inbox_id = inboxId;
if (dateRange?.type) {
const { type, from: dateFrom, to: dateTo } = dateRange;
params.range = type;
if (dateFrom) params.since = dateFrom;
if (dateTo) params.until = dateTo;
}
}
return params;
};
export const parseURLParams = (query, isAdvancedSearchEnabled = true) => {
// If advanced search is disabled, return empty filters
if (!isAdvancedSearchEnabled) {
return {
from: null,
in: null,
dateRange: {
type: null,
from: null,
to: null,
},
};
}
const { from, inbox_id, since, until, range } = query;
let type = range;
if (!type && (since || until)) {
type = DATE_RANGE_TYPES.BETWEEN;
}
return {
from: from || null,
in: inbox_id ? Number(inbox_id) : null,
dateRange: {
type,
from: since ? Number(since) : null,
to: until ? Number(until) : null,
},
};
};
export const fetchContactDetails = async id => {
try {
const response = await ContactAPI.show(id);
return response.data.payload;
} catch (error) {
// error
return null;
}
};
@@ -1,379 +0,0 @@
import ContactAPI from 'dashboard/api/contacts';
import {
DATE_RANGE_TYPES,
generateURLParams,
parseURLParams,
fetchContactDetails,
} from '../searchHelper';
// Mock ContactAPI
vi.mock('dashboard/api/contacts', () => ({
default: {
show: vi.fn(),
},
}));
describe('#generateURLParams', () => {
it('returns empty object when no parameters provided', () => {
expect(generateURLParams({})).toEqual({});
});
it('generates params with from parameter', () => {
const result = generateURLParams({ from: 'agent:123' });
expect(result).toEqual({ from: 'agent:123' });
});
it('generates params with inbox_id parameter', () => {
const result = generateURLParams({ in: 456 });
expect(result).toEqual({ inbox_id: 456 });
});
it('generates params with all basic parameters', () => {
const result = generateURLParams({
from: 'contact:789',
in: 123,
});
expect(result).toEqual({
from: 'contact:789',
inbox_id: 123,
});
});
describe('with date range', () => {
it('generates params with date range type only', () => {
const result = generateURLParams({
dateRange: { type: DATE_RANGE_TYPES.LAST_7_DAYS },
});
expect(result).toEqual({
range: 'last_7_days',
});
});
it('generates params with BETWEEN date range', () => {
const result = generateURLParams({
dateRange: {
type: DATE_RANGE_TYPES.BETWEEN,
from: 1640995200,
to: 1672531199,
},
});
expect(result).toEqual({
range: 'between',
since: 1640995200,
until: 1672531199,
});
});
it('generates params with CUSTOM date range', () => {
const result = generateURLParams({
dateRange: {
type: DATE_RANGE_TYPES.CUSTOM,
from: 1640995200,
to: 1672531199,
},
});
expect(result).toEqual({
range: 'custom',
since: 1640995200,
until: 1672531199,
});
});
it('handles date range with missing from/to values', () => {
const result = generateURLParams({
dateRange: {
type: DATE_RANGE_TYPES.BETWEEN,
from: null,
to: undefined,
},
});
expect(result).toEqual({
range: 'between',
});
});
});
it('generates params with all parameters combined', () => {
const result = generateURLParams({
from: 'agent:456',
in: 789,
dateRange: {
type: DATE_RANGE_TYPES.BETWEEN,
from: 1640995200,
to: 1672531199,
},
});
expect(result).toEqual({
from: 'agent:456',
inbox_id: 789,
range: 'between',
since: 1640995200,
until: 1672531199,
});
});
describe('when advanced search is disabled', () => {
it('returns empty object when isAdvancedSearchEnabled is false', () => {
const result = generateURLParams(
{
from: 'agent:123',
in: 456,
dateRange: {
type: DATE_RANGE_TYPES.BETWEEN,
from: 1640995200,
to: 1672531199,
},
},
false
);
expect(result).toEqual({});
});
it('strips all filter params when feature flag is disabled', () => {
const result = generateURLParams(
{
from: 'contact:789',
in: 123,
},
false
);
expect(result).toEqual({});
});
it('strips date range params when feature flag is disabled', () => {
const result = generateURLParams(
{
dateRange: {
type: DATE_RANGE_TYPES.LAST_7_DAYS,
from: 1640995200,
to: 1672531199,
},
},
false
);
expect(result).toEqual({});
});
});
});
describe('#parseURLParams', () => {
it('returns default values for empty query', () => {
const result = parseURLParams({});
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: undefined,
from: null,
to: null,
},
});
});
it('parses from parameter', () => {
const result = parseURLParams({ from: 'agent:123' });
expect(result).toEqual({
from: 'agent:123',
in: null,
dateRange: {
type: undefined,
from: null,
to: null,
},
});
});
it('parses inbox_id parameter as number', () => {
const result = parseURLParams({ inbox_id: '456' });
expect(result).toEqual({
from: null,
in: 456,
dateRange: {
type: undefined,
from: null,
to: null,
},
});
});
it('parses explicit range parameter', () => {
const result = parseURLParams({
range: 'last_7_days',
since: '1640995200',
until: '1672531199',
});
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: 'last_7_days',
from: 1640995200,
to: 1672531199,
},
});
});
describe('inferred date range types', () => {
it('infers BETWEEN type when both since and until are present', () => {
const result = parseURLParams({
since: '1640995200',
until: '1672531199',
});
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: 'between',
from: 1640995200,
to: 1672531199,
},
});
});
it('prioritizes explicit range over inferred type', () => {
const result = parseURLParams({
range: 'custom',
since: '1640995200',
until: '1672531199',
});
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: 'custom',
from: 1640995200,
to: 1672531199,
},
});
});
});
it('parses all parameters combined', () => {
const result = parseURLParams({
from: 'contact:789',
inbox_id: '123',
range: 'between',
since: '1640995200',
until: '1672531199',
});
expect(result).toEqual({
from: 'contact:789',
in: 123,
dateRange: {
type: 'between',
from: 1640995200,
to: 1672531199,
},
});
});
describe('when advanced search is disabled', () => {
it('returns empty filters when isAdvancedSearchEnabled is false', () => {
const result = parseURLParams(
{
from: 'agent:123',
inbox_id: '456',
range: 'between',
since: '1640995200',
until: '1672531199',
},
false
);
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: null,
from: null,
to: null,
},
});
});
it('ignores all filter params from URL when feature flag is disabled', () => {
const result = parseURLParams(
{
from: 'contact:789',
inbox_id: '123',
},
false
);
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: null,
from: null,
to: null,
},
});
});
it('ignores date range params from URL when feature flag is disabled', () => {
const result = parseURLParams(
{
range: 'last_7_days',
since: '1640995200',
until: '1672531199',
},
false
);
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: null,
from: null,
to: null,
},
});
});
});
});
describe('#fetchContactDetails', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns contact data on successful API call', async () => {
const mockContactData = {
id: 123,
name: 'John Doe',
email: 'john@example.com',
};
ContactAPI.show.mockResolvedValue({
data: {
payload: mockContactData,
},
});
const result = await fetchContactDetails(123);
expect(result).toEqual(mockContactData);
expect(ContactAPI.show).toHaveBeenCalledWith(123);
});
it('returns null on API error', async () => {
ContactAPI.show.mockRejectedValue(new Error('API Error'));
const result = await fetchContactDetails(123);
expect(result).toBeNull();
expect(ContactAPI.show).toHaveBeenCalledWith(123);
});
it('handles different contact ID types', async () => {
const mockContactData = { id: 456, name: 'Jane Doe' };
ContactAPI.show.mockResolvedValue({
data: { payload: mockContactData },
});
// Test with string ID
await fetchContactDetails('456');
expect(ContactAPI.show).toHaveBeenCalledWith('456');
// Test with number ID
await fetchContactDetails(456);
expect(ContactAPI.show).toHaveBeenCalledWith(456);
});
});
@@ -6,7 +6,6 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, onMounted, ref, defineOptions } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -18,8 +17,6 @@ const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const showAddPopup = ref(false);
const loading = ref({});
const showEditPopup = ref(false);
@@ -185,7 +182,7 @@ const tableHeaders = computed(() => {
{{ cannedItem.short_code }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 md:break-all whitespace-normal">
{{ getPlainText(cannedItem.content) }}
{{ cannedItem.content }}
</td>
<td class="py-4 flex justify-end gap-1">
<Button
@@ -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>
@@ -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;
}
},
},
});
@@ -57,9 +57,8 @@ export const actions = {
});
}
},
async fullSearch({ commit, dispatch }, payload) {
const { q, ...filters } = payload;
if (!q && !Object.keys(filters).length) {
async fullSearch({ commit, dispatch }, { q }) {
if (!q) {
return;
}
commit(types.FULL_SEARCH_SET_UI_FLAG, {
@@ -68,10 +67,10 @@ export const actions = {
});
try {
await Promise.all([
dispatch('contactSearch', { q, ...filters }),
dispatch('conversationSearch', { q, ...filters }),
dispatch('messageSearch', { q, ...filters }),
dispatch('articleSearch', { q, ...filters }),
dispatch('contactSearch', { q }),
dispatch('conversationSearch', { q }),
dispatch('messageSearch', { q }),
dispatch('articleSearch', { q }),
]);
} catch (error) {
// Ignore error
@@ -82,11 +81,10 @@ export const actions = {
});
}
},
async contactSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
async contactSearch({ commit }, { q, page = 1 }) {
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.contacts({ ...searchParams, page });
const { data } = await SearchAPI.contacts({ q, page });
commit(types.CONTACT_SEARCH_SET, data.payload.contacts);
} catch (error) {
// Ignore error
@@ -94,11 +92,10 @@ export const actions = {
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async conversationSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
async conversationSearch({ commit }, { q, page = 1 }) {
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.conversations({ ...searchParams, page });
const { data } = await SearchAPI.conversations({ q, page });
commit(types.CONVERSATION_SEARCH_SET, data.payload.conversations);
} catch (error) {
// Ignore error
@@ -106,11 +103,10 @@ export const actions = {
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async messageSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
async messageSearch({ commit }, { q, page = 1 }) {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.messages({ ...searchParams, page });
const { data } = await SearchAPI.messages({ q, page });
commit(types.MESSAGE_SEARCH_SET, data.payload.messages);
} catch (error) {
// Ignore error
@@ -118,11 +114,10 @@ export const actions = {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async articleSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
async articleSearch({ commit }, { q, page = 1 }) {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.articles({ ...searchParams, page });
const { data } = await SearchAPI.articles({ q, page });
commit(types.ARTICLE_SEARCH_SET, data.payload.articles);
} catch (error) {
// Ignore error
@@ -77,16 +77,6 @@ describe('#actions', () => {
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
});
it('should pass filters to all search actions including articleSearch', async () => {
const payload = { q: 'test', since: 1700000000, until: 1732000000 };
await actions.fullSearch({ commit, dispatch }, payload);
expect(dispatch).toHaveBeenCalledWith('contactSearch', payload);
expect(dispatch).toHaveBeenCalledWith('conversationSearch', payload);
expect(dispatch).toHaveBeenCalledWith('messageSearch', payload);
expect(dispatch).toHaveBeenCalledWith('articleSearch', payload);
});
});
describe('#contactSearch', () => {
@@ -175,22 +165,6 @@ describe('#actions', () => {
]);
});
it('should handle article search with date filters', async () => {
axios.get.mockResolvedValue({
data: { payload: { articles: [{ id: 1 }] } },
});
await actions.articleSearch(
{ commit },
{ q: 'test', page: 1, since: 1700000000, until: 1732000000 }
);
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET, [{ id: 1 }]],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed article search', async () => {
axios.get.mockRejectedValue({});
await actions.articleSearch({ commit }, { q: 'test' });
@@ -1,130 +0,0 @@
import { nextTick } from 'vue';
import { useExpandableContent } from '../useExpandableContent';
// Mock VueUse composables
vi.mock('@vueuse/core', () => ({
useToggle: vi.fn(initialValue => {
let value = initialValue;
const toggle = newValue => {
value = newValue !== undefined ? newValue : !value;
};
return [{ value }, toggle];
}),
useResizeObserver: vi.fn((element, callback) => {
// Store callback for manual triggering in tests
if (element.value) {
callback();
}
}),
}));
describe('useExpandableContent', () => {
let originalGetComputedStyle;
beforeEach(() => {
// Mock window.getComputedStyle
originalGetComputedStyle = window.getComputedStyle;
window.getComputedStyle = vi.fn(() => ({
lineHeight: '20px',
}));
});
afterEach(() => {
window.getComputedStyle = originalGetComputedStyle;
vi.clearAllMocks();
});
it('returns expected properties', () => {
const result = useExpandableContent();
expect(result).toHaveProperty('contentElement');
expect(result).toHaveProperty('isExpanded');
expect(result).toHaveProperty('needsToggle');
expect(result).toHaveProperty('toggleExpanded');
expect(result).toHaveProperty('checkOverflow');
});
it('initializes with default values', () => {
const { isExpanded, needsToggle } = useExpandableContent();
expect(isExpanded.value).toBe(false);
expect(needsToggle.value).toBe(false);
});
it('checkOverflow sets needsToggle to true when content overflows', async () => {
const { contentElement, needsToggle, checkOverflow } =
useExpandableContent();
// Mock element with overflow
contentElement.value = {
scrollHeight: 100, // Much larger than 2 lines (40px)
};
checkOverflow();
await nextTick();
expect(needsToggle.value).toBe(true);
});
it('checkOverflow sets needsToggle to false when content fits', async () => {
const { contentElement, needsToggle, checkOverflow } =
useExpandableContent();
// Mock element without overflow
contentElement.value = {
scrollHeight: 30, // Less than 2 lines (40px)
};
checkOverflow();
await nextTick();
expect(needsToggle.value).toBe(false);
});
it('respects custom maxLines option', async () => {
const { contentElement, needsToggle, checkOverflow } = useExpandableContent(
{
maxLines: 3,
}
);
// Mock element that fits in 3 lines but not 2
contentElement.value = {
scrollHeight: 50, // Fits in 3 lines (60px) but not 2 lines (40px)
};
checkOverflow();
await nextTick();
expect(needsToggle.value).toBe(false);
});
it('uses defaultLineHeight when computed style is unavailable', async () => {
window.getComputedStyle = vi.fn(() => ({
lineHeight: 'normal', // Not a valid number
}));
const { contentElement, needsToggle, checkOverflow } = useExpandableContent(
{
defaultLineHeight: 16,
}
);
// Mock element that overflows with 16px line height (32px max)
contentElement.value = {
scrollHeight: 40,
};
checkOverflow();
await nextTick();
expect(needsToggle.value).toBe(true);
});
it('handles null contentElement gracefully', () => {
const { checkOverflow } = useExpandableContent();
// Should not throw when contentElement is null
expect(() => checkOverflow()).not.toThrow();
});
});
@@ -1,62 +0,0 @@
import { ref, computed, nextTick, onMounted } from 'vue';
import { useToggle, useResizeObserver } from '@vueuse/core';
/**
* Composable for handling expandable content with "Read more / Read less" functionality.
* Detects content overflow and provides toggle state for expansion.
*
* @param {Object} options - Configuration options
* @param {number} [options.maxLines=2] - Maximum number of lines before showing toggle
* @param {number} [options.defaultLineHeight=20] - Fallback line height if computed style is unavailable
* @param {boolean} [options.useResizeObserverForCheck=false] - Use ResizeObserver for continuous overflow checking
* @returns {Object} - Composable state and methods
*/
export function useExpandableContent(options = {}) {
const {
maxLines = 2,
defaultLineHeight = 20,
useResizeObserverForCheck = false,
} = options;
const contentElement = ref(null);
const [isExpanded, toggleExpanded] = useToggle(false);
const needsToggle = ref(false);
const showReadMore = computed(() => needsToggle.value && !isExpanded.value);
const showReadLess = computed(() => needsToggle.value && isExpanded.value);
/**
* Checks if content overflows the maximum allowed height
* and updates needsToggle accordingly
*/
const checkOverflow = () => {
if (!contentElement.value) return;
const element = contentElement.value;
const computedStyle = window.getComputedStyle(element);
const lineHeight =
parseFloat(computedStyle.lineHeight) || defaultLineHeight;
const maxHeight = lineHeight * maxLines;
needsToggle.value = element.scrollHeight > maxHeight;
};
// Setup overflow checking based on configuration
if (useResizeObserverForCheck) {
useResizeObserver(contentElement, checkOverflow);
} else {
onMounted(() => {
nextTick(checkOverflow);
});
}
return {
contentElement,
isExpanded,
needsToggle,
showReadMore,
showReadLess,
toggleExpanded,
checkOverflow,
};
}
+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
+5 -19
View File
@@ -322,21 +322,12 @@ class Message < ApplicationRecord
end
def update_waiting_since
waiting_present = conversation.waiting_since.present?
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
if human_response? && !private && conversation.waiting_since.present?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
end
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
@@ -350,11 +341,6 @@ class Message < ApplicationRecord
sender.is_a?(User)
end
def bot_response?
# Check if this is a response from AgentBot or Captain::Assistant
outgoing? && sender_type.in?(['AgentBot', 'Captain::Assistant'])
end
def dispatch_create_events
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
+1 -1
View File
@@ -53,7 +53,7 @@ class Portal < ApplicationRecord
file_type: logo.content_type,
account_id: account_id,
file_url: url_for(logo),
blob_id: logo.blob.signed_id,
blob_id: logo.blob_id,
filename: logo.filename.to_s
}
end
+1 -1
View File
@@ -5,7 +5,7 @@
# id :bigint not null, primary key
# name :string
# subscriptions :jsonb
# url :text
# url :string
# webhook_type :integer default("account_type")
# created_at :datetime not null
# updated_at :datetime not null
-11
View File
@@ -95,7 +95,6 @@ class MailPresenter < SimpleDelegator
content_type: content_type,
date: date,
from: from,
headers: headers_data,
html_content: html_content,
in_reply_to: in_reply_to,
message_id: message_id,
@@ -137,16 +136,6 @@ class MailPresenter < SimpleDelegator
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
end
def headers_data
headers = {
'x-original-from' => @mail['X-Original-From']&.value,
'x-original-sender' => @mail['X-Original-Sender']&.value,
'x-forwarded-for' => @mail['X-Forwarded-For']&.value
}.compact
headers.presence
end
def from_email_address(email)
Mail::Address.new(email).address
end
+18 -99
View File
@@ -31,38 +31,25 @@ class SearchService
end
def filter_conversations
conversations_query = current_account.conversations.where(inbox_id: accessable_inbox_ids)
.joins('INNER JOIN contacts ON conversations.contact_id = contacts.id')
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
@conversations = current_account.conversations.where(inbox_id: accessable_inbox_ids)
.joins('INNER JOIN contacts ON conversations.contact_id = contacts.id')
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
ILIKE :search OR contacts.phone_number ILIKE :search OR contacts.identifier ILIKE :search", search: "%#{search_query}%")
if current_account.feature_enabled?('advanced_search')
conversations_query = apply_time_filter(conversations_query,
'conversations.last_activity_at')
end
@conversations = conversations_query.order('conversations.created_at DESC')
.page(params[:page])
.per(15)
.order('conversations.created_at DESC')
.page(params[:page])
.per(15)
end
def filter_messages
@messages = if use_gin_search
filter_messages_with_gin
elsif should_run_advanced_search?
advanced_search_with_fallback
advanced_search
else
filter_messages_with_like
end
end
def advanced_search_with_fallback
advanced_search
rescue Faraday::ConnectionFailed, Searchkick::Error, Elasticsearch::Transport::Transport::Error => e
Rails.logger.warn("Elasticsearch unavailable, falling back to SQL search: #{e.message}")
use_gin_search ? filter_messages_with_gin : filter_messages_with_like
end
def should_run_advanced_search?
ChatwootApp.advanced_search_allowed? && current_account.feature_enabled?('advanced_search')
end
@@ -71,7 +58,6 @@ class SearchService
def filter_messages_with_gin
base_query = message_base_query
base_query = apply_message_filters(base_query)
if search_query.present?
# Use the @@ operator with to_tsquery for better GIN index utilization
@@ -96,12 +82,11 @@ class SearchService
end
def filter_messages_with_like
base_query = message_base_query
base_query = apply_message_filters(base_query)
base_query.where('messages.content ILIKE :search', search: "%#{search_query}%")
.reorder('created_at DESC')
.page(params[:page])
.per(15)
message_base_query
.where('messages.content ILIKE :search', search: "%#{search_query}%")
.reorder('created_at DESC')
.page(params[:page])
.per(15)
end
def message_base_query
@@ -110,45 +95,6 @@ class SearchService
query
end
def apply_message_filters(query)
return query unless current_account.feature_enabled?('advanced_search')
query = apply_time_filter(query, 'messages.created_at')
query = apply_sender_filter(query)
apply_inbox_id_filter(query)
end
def apply_sender_filter(query)
sender_type, sender_id = parse_from_param(params[:from])
return query unless sender_type && sender_id
query.where(sender_type: sender_type, sender_id: sender_id)
end
def parse_from_param(from_param)
return [nil, nil] unless from_param&.match?(/\A(contact|agent):\d+\z/)
type, id = from_param.split(':')
sender_type = type == 'agent' ? 'User' : 'Contact'
[sender_type, id.to_i]
end
def apply_inbox_id_filter(query)
return query if params[:inbox_id].blank?
inbox_id = params[:inbox_id].to_i
return query if inbox_id.zero?
return query unless validate_inbox_access(inbox_id)
query.where(inbox_id: inbox_id)
end
def validate_inbox_access(inbox_id)
return true if should_skip_inbox_filtering?
accessable_inbox_ids.include?(inbox_id)
end
def should_skip_inbox_filtering?
account_user.administrator? || user_has_access_to_all_inboxes?
end
@@ -162,46 +108,19 @@ class SearchService
end
def filter_contacts
contacts_query = current_account.contacts.where(
@contacts = current_account.contacts.where(
"name ILIKE :search OR email ILIKE :search OR phone_number
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
)
contacts_query = apply_time_filter(contacts_query, 'last_activity_at') if current_account.feature_enabled?('advanced_search')
@contacts = contacts_query.resolved_contacts(
).resolved_contacts(
use_crm_v2: current_account.feature_enabled?('crm_v2')
).order_on_last_activity_at('desc').page(params[:page]).per(15)
end
def filter_articles
articles_query = current_account.articles.text_search(search_query)
articles_query = apply_time_filter(articles_query, 'updated_at') if current_account.feature_enabled?('advanced_search')
@articles = articles_query.page(params[:page]).per(15)
end
def apply_time_filter(query, column_name)
return query if params[:since].blank? && params[:until].blank?
query = query.where("#{column_name} >= ?", cap_since_time(params[:since])) if params[:since].present?
query = query.where("#{column_name} <= ?", cap_until_time(params[:until])) if params[:until].present?
query
end
def cap_since_time(since_param)
max_lookback = 90.days.ago
requested_time = Time.zone.at(since_param.to_i)
# Silently cap to max_lookback if requested time is too far back
[requested_time, max_lookback].max
end
def cap_until_time(until_param)
max_future = 90.days.from_now
requested_time = Time.zone.at(until_param.to_i)
[requested_time, max_future].min
@articles = current_account.articles
.text_search(search_query)
.page(params[:page])
.per(15)
end
end
@@ -6,5 +6,3 @@ json.slug article.slug
json.portal_slug article.portal.slug
json.account_id article.account_id
json.category_name article.category&.name
json.status article.status
json.updated_at article.updated_at.to_i
@@ -3,5 +3,3 @@ json.id contact.id
json.name contact.name
json.phone_number contact.phone_number
json.identifier contact.identifier
json.additional_attributes contact.additional_attributes
json.last_activity_at contact.last_activity_at&.to_i
@@ -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
+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
-3
View File
@@ -84,9 +84,6 @@ en:
invalid: must be a valid domain name
search:
query_missing: Specify search string with parameter q
messages:
search:
time_range_limit_exceeded: 'Search is limited to the last %{days} days'
categories:
locale:
unique: should be unique in the category and portal
+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
@@ -1,9 +0,0 @@
class ChangeWebhookUrlToText < ActiveRecord::Migration[7.1]
def up
change_column :webhooks, :url, :text
end
def down
change_column :webhooks, :url, :string
end
end
+2 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1239,7 +1239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
create_table "webhooks", force: :cascade do |t|
t.integer "account_id"
t.integer "inbox_id"
t.text "url"
t.string "url"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "webhook_type", default: 0
@@ -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: {
@@ -30,7 +30,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.display_id).generate_response(
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.id).generate_response(
message_history: collect_previous_messages
)
process_response
@@ -45,4 +45,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
def feature_name
'assistant'
end
def account
@assistant&.account
end
end
@@ -33,10 +33,9 @@ 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,
conversation_id: @conversation.display_id,
feature_name: 'contact_attributes',
messages: [
{ role: 'system', content: system_prompt },
@@ -34,10 +34,9 @@ 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,
conversation_id: @conversation.display_id,
feature_name: 'contact_notes',
messages: [
{ role: 'system', content: system_prompt },
@@ -97,10 +97,10 @@ 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.display_id,
conversation_id: @conversation.id,
feature_name: 'conversation_faq',
messages: [
{ role: 'system', content: system_prompt },
@@ -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: [
@@ -1,7 +1,7 @@
module Enterprise::SearchService
def advanced_search
where_conditions = build_where_conditions
apply_filters(where_conditions)
where_conditions = { account_id: current_account.id }
where_conditions[:inbox_id] = accessable_inbox_ids unless should_skip_inbox_filtering?
Message.search(
search_query,
@@ -12,77 +12,4 @@ module Enterprise::SearchService
per_page: 15
)
end
private
def build_where_conditions
conditions = { account_id: current_account.id }
conditions[:inbox_id] = accessable_inbox_ids unless should_skip_inbox_filtering?
conditions
end
def apply_filters(where_conditions)
apply_from_filter(where_conditions)
apply_time_range_filter(where_conditions)
apply_inbox_filter(where_conditions)
end
def apply_from_filter(where_conditions)
sender_type, sender_id = parse_from_param(params[:from])
return unless sender_type && sender_id
where_conditions[:sender_type] = sender_type
where_conditions[:sender_id] = sender_id
end
def parse_from_param(from_param)
return [nil, nil] unless from_param&.match?(/\A(contact|agent):\d+\z/)
type, id = from_param.split(':')
sender_type = type == 'agent' ? 'User' : 'Contact'
[sender_type, id.to_i]
end
def apply_time_range_filter(where_conditions)
time_conditions = {}
time_conditions[:gte] = enforce_time_limit(params[:since])
time_conditions[:lte] = cap_until_time(params[:until]) if params[:until].present?
where_conditions[:created_at] = time_conditions if time_conditions.any?
end
def cap_until_time(until_param)
max_future = 90.days.from_now
requested_time = Time.zone.at(until_param.to_i)
[requested_time, max_future].min
end
def enforce_time_limit(since_param)
max_lookback = Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS.days.ago
if since_param.present?
requested_time = Time.zone.at(since_param.to_i)
# Silently cap to max_lookback if requested time is too far back
[requested_time, max_lookback].max
else
max_lookback
end
end
def apply_inbox_filter(where_conditions)
return if params[:inbox_id].blank?
inbox_id = params[:inbox_id].to_i
return if inbox_id.zero?
return unless validate_inbox_access(inbox_id)
where_conditions[:inbox_id] = inbox_id
end
def validate_inbox_access(inbox_id)
return true if should_skip_inbox_filtering?
accessable_inbox_ids.include?(inbox_id)
end
end
+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,4 +1,4 @@
class Messages::AudioTranscriptionService
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
WHISPER_MODEL = 'whisper-1'.freeze
@@ -6,7 +6,7 @@ class Messages::AudioTranscriptionService
attr_reader :attachment, :message, :account
def initialize(attachment)
Llm::Config.initialize!
super()
@attachment = attachment
@message = attachment.message
@account = message.account
@@ -50,18 +50,23 @@ class Messages::AudioTranscriptionService
temp_file_path = fetch_audio_file
transcription = instrument_audio_transcription(instrumentation_params(temp_file_path)) do
RubyLLM.transcribe(temp_file_path, model: WHISPER_MODEL)
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: {
model: 'whisper-1',
file: file,
temperature: 0.4
}
)
transcribed_text = response['text']
end
FileUtils.rm_f(temp_file_path)
update_transcription(transcription.text)
transcription.text
rescue RubyLLM::Error => e
FileUtils.rm_f(temp_file_path) if temp_file_path
Rails.logger.error "Audio transcription error: #{e.message}"
raise
update_transcription(transcribed_text)
transcribed_text
end
def instrumentation_params(file_path)
@@ -69,7 +74,6 @@ class Messages::AudioTranscriptionService
span_name: 'llm.messages.audio_transcription',
model: WHISPER_MODEL,
account_id: account&.id,
conversation_id: message.conversation&.display_id,
feature_name: 'audio_transcription',
file_path: file_path
}
@@ -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',
@@ -11,7 +11,7 @@ class Integrations::GoogleTranslate::ProcessorService
response = client.translate_text(
contents: [content],
target_language_code: bcp47_language_code,
target_language_code: target_language,
parent: "projects/#{hook.settings['project_id']}",
mime_type: mime_type
)
@@ -23,10 +23,6 @@ class Integrations::GoogleTranslate::ProcessorService
private
def bcp47_language_code
target_language.tr('_', '-')
end
def email_channel?
message&.inbox&.email?
end
+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
View File
@@ -9,7 +9,6 @@ module Limits
COMPANY_NAME_LENGTH_LIMIT = 100
COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
MAX_CUSTOM_FILTERS_PER_USER = 1000
MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS = 90
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
+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

Some files were not shown because too many files have changed in this diff Show More