Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6343cd1631 | ||
|
|
88666b887e | ||
|
|
ae3687364d | ||
|
|
4eed216560 | ||
|
|
2b14cd1268 | ||
|
|
9fc86b13cb | ||
|
|
871ff9e1eb | ||
|
|
a64ce2f209 | ||
|
|
aa3b406ea5 | ||
|
|
def8a4bf99 | ||
|
|
fc8b2d6c72 | ||
|
|
2b0320ca7a | ||
|
|
1b00a60836 | ||
|
|
ac64840ad7 | ||
|
|
529f22954e | ||
|
|
c711ffb85a | ||
|
|
8f1f9cb426 | ||
|
|
6dc9e2a63b | ||
|
|
9ab5a27367 | ||
|
|
6257641837 | ||
|
|
d5a8673c82 | ||
|
|
8a54cf41bd | ||
|
|
7fd7704fea | ||
|
|
ad1fc1490a | ||
|
|
da4d41f615 | ||
|
|
0cf89a1dda | ||
|
|
566066b0d3 | ||
|
|
5b5e6a852f | ||
|
|
350fdd0fa0 | ||
|
|
2161690ec4 | ||
|
|
379bfa1b89 | ||
|
|
d75e1393d8 | ||
|
|
4ac6c19c19 | ||
|
|
0a0f96ea0a | ||
|
|
4f332a8a74 | ||
|
|
aedd33a76f | ||
|
|
80fe8ca493 | ||
|
|
f084dc77c8 | ||
|
|
e7507d7627 | ||
|
|
26db79584b | ||
|
|
d95d63d59d | ||
|
|
d421da3537 | ||
|
|
b31b220885 | ||
|
|
5f689518af | ||
|
|
c630a609aa | ||
|
|
5d1713485c | ||
|
|
1dde9329dd | ||
|
|
bf76fd3769 | ||
|
|
a9a113bace | ||
|
|
65e3f8231f | ||
|
|
f2d3a6ee4f | ||
|
|
b67dbf3761 | ||
|
|
a49521c9d6 |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
*Chatwoot* © 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
|
||||
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
+43
-68
@@ -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>
|
||||
|
||||
+4
-5
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
+153
@@ -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>
|
||||
+151
@@ -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>
|
||||
+36
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user