Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e1f79025e | ||
|
|
e84efdccbd | ||
|
|
44da47c845 | ||
|
|
0ebe187c8a | ||
|
|
20d6a11dd9 | ||
|
|
5dc5037903 | ||
|
|
3c7b4f6011 | ||
|
|
73f2aea514 | ||
|
|
02424a5a09 | ||
|
|
b015c61d71 | ||
|
|
d483974ef1 | ||
|
|
b16b1b6a49 | ||
|
|
b28d00a117 | ||
|
|
2b3ad3dc74 | ||
|
|
b95944dd4e | ||
|
|
75ba8e9186 | ||
|
|
31969428ae | ||
|
|
a4fab22e28 | ||
|
|
5d27fd798f | ||
|
|
ca14eba448 | ||
|
|
199a55a8e3 | ||
|
|
873094175b | ||
|
|
096bffa893 | ||
|
|
ccbc2c5d56 | ||
|
|
2c0f235d1d | ||
|
|
fc067b7f66 | ||
|
|
eacfa2e19a |
+49
-196
@@ -3,7 +3,6 @@ orbs:
|
|||||||
node: circleci/node@6.1.0
|
node: circleci/node@6.1.0
|
||||||
qlty-orb: qltysh/qlty-orb@0.0
|
qlty-orb: qltysh/qlty-orb@0.0
|
||||||
|
|
||||||
# Shared defaults for setup steps
|
|
||||||
defaults: &defaults
|
defaults: &defaults
|
||||||
working_directory: ~/build
|
working_directory: ~/build
|
||||||
machine:
|
machine:
|
||||||
@@ -13,106 +12,10 @@ defaults: &defaults
|
|||||||
RAILS_LOG_TO_STDOUT: false
|
RAILS_LOG_TO_STDOUT: false
|
||||||
COVERAGE: true
|
COVERAGE: true
|
||||||
LOG_LEVEL: warn
|
LOG_LEVEL: warn
|
||||||
|
parallelism: 4
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Separate job for linting (no parallelism needed)
|
build:
|
||||||
lint:
|
|
||||||
<<: *defaults
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
|
|
||||||
# Install minimal system dependencies for linting
|
|
||||||
- run:
|
|
||||||
name: Install System Dependencies
|
|
||||||
command: |
|
|
||||||
sudo apt-get update
|
|
||||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
|
|
||||||
libpq-dev \
|
|
||||||
build-essential \
|
|
||||||
git \
|
|
||||||
curl \
|
|
||||||
libssl-dev \
|
|
||||||
zlib1g-dev \
|
|
||||||
libreadline-dev \
|
|
||||||
libyaml-dev \
|
|
||||||
openjdk-11-jdk \
|
|
||||||
jq \
|
|
||||||
software-properties-common \
|
|
||||||
ca-certificates \
|
|
||||||
imagemagick \
|
|
||||||
libxml2-dev \
|
|
||||||
libxslt1-dev \
|
|
||||||
file \
|
|
||||||
g++ \
|
|
||||||
gcc \
|
|
||||||
autoconf \
|
|
||||||
gnupg2 \
|
|
||||||
patch \
|
|
||||||
ruby-dev \
|
|
||||||
liblzma-dev \
|
|
||||||
libgmp-dev \
|
|
||||||
libncurses5-dev \
|
|
||||||
libffi-dev \
|
|
||||||
libgdbm6 \
|
|
||||||
libgdbm-dev \
|
|
||||||
libvips
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install RVM and Ruby 3.4.4
|
|
||||||
command: |
|
|
||||||
sudo apt-get install -y gpg
|
|
||||||
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
|
||||||
\curl -sSL https://get.rvm.io | bash -s stable
|
|
||||||
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
|
||||||
source ~/.rvm/scripts/rvm
|
|
||||||
rvm install "3.4.4"
|
|
||||||
rvm use 3.4.4 --default
|
|
||||||
gem install bundler -v 2.5.16
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install Application Dependencies
|
|
||||||
command: |
|
|
||||||
source ~/.rvm/scripts/rvm
|
|
||||||
bundle install
|
|
||||||
|
|
||||||
- node/install:
|
|
||||||
node-version: '23.7'
|
|
||||||
- node/install-pnpm
|
|
||||||
- node/install-packages:
|
|
||||||
pkg-manager: pnpm
|
|
||||||
override-ci-command: pnpm i
|
|
||||||
|
|
||||||
# Swagger verification
|
|
||||||
- run:
|
|
||||||
name: Verify swagger API specification
|
|
||||||
command: |
|
|
||||||
bundle exec rake swagger:build
|
|
||||||
if [[ `git status swagger/swagger.json --porcelain` ]]
|
|
||||||
then
|
|
||||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p ~/tmp
|
|
||||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
|
||||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
|
||||||
|
|
||||||
# Bundle audit
|
|
||||||
- run:
|
|
||||||
name: Bundle audit
|
|
||||||
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
|
||||||
|
|
||||||
# Rubocop linting
|
|
||||||
- run:
|
|
||||||
name: Rubocop
|
|
||||||
command: bundle exec rubocop --parallel
|
|
||||||
|
|
||||||
# ESLint linting
|
|
||||||
- run:
|
|
||||||
name: eslint
|
|
||||||
command: pnpm run eslint
|
|
||||||
|
|
||||||
# Separate job for frontend tests
|
|
||||||
frontend-tests:
|
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@@ -122,38 +25,8 @@ jobs:
|
|||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
override-ci-command: pnpm i
|
override-ci-command: pnpm i
|
||||||
|
- run: node --version
|
||||||
- run:
|
- run: pnpm --version
|
||||||
name: Run frontend tests (with coverage)
|
|
||||||
command: pnpm run test:coverage
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Move coverage files if they exist
|
|
||||||
command: |
|
|
||||||
if [ -d "coverage" ]; then
|
|
||||||
mkdir -p ~/build/coverage
|
|
||||||
cp -r coverage ~/build/coverage/frontend || true
|
|
||||||
fi
|
|
||||||
when: always
|
|
||||||
|
|
||||||
- persist_to_workspace:
|
|
||||||
root: ~/build
|
|
||||||
paths:
|
|
||||||
- coverage
|
|
||||||
|
|
||||||
# Backend tests with parallelization
|
|
||||||
backend-tests:
|
|
||||||
<<: *defaults
|
|
||||||
parallelism: 16
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
- node/install:
|
|
||||||
node-version: '23.7'
|
|
||||||
- node/install-pnpm
|
|
||||||
- node/install-packages:
|
|
||||||
pkg-manager: pnpm
|
|
||||||
override-ci-command: pnpm i
|
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Add PostgreSQL repository and update
|
name: Add PostgreSQL repository and update
|
||||||
command: |
|
command: |
|
||||||
@@ -218,6 +91,20 @@ jobs:
|
|||||||
source ~/.rvm/scripts/rvm
|
source ~/.rvm/scripts/rvm
|
||||||
bundle install
|
bundle install
|
||||||
|
|
||||||
|
# Swagger verification
|
||||||
|
- run:
|
||||||
|
name: Verify swagger API specification
|
||||||
|
command: |
|
||||||
|
bundle exec rake swagger:build
|
||||||
|
if [[ `git status swagger/swagger.json --porcelain` ]]
|
||||||
|
then
|
||||||
|
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p ~/tmp
|
||||||
|
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||||
|
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||||
|
|
||||||
# Configure environment and database
|
# Configure environment and database
|
||||||
- run:
|
- run:
|
||||||
name: Database Setup and Configure Environment Variables
|
name: Database Setup and Configure Environment Variables
|
||||||
@@ -240,91 +127,57 @@ jobs:
|
|||||||
name: Run DB migrations
|
name: Run DB migrations
|
||||||
command: bundle exec rails db:chatwoot_prepare
|
command: bundle exec rails db:chatwoot_prepare
|
||||||
|
|
||||||
# Run backend tests (parallelized)
|
# Bundle audit
|
||||||
|
- run:
|
||||||
|
name: Bundle audit
|
||||||
|
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
||||||
|
|
||||||
|
# Rubocop linting
|
||||||
|
- run:
|
||||||
|
name: Rubocop
|
||||||
|
command: bundle exec rubocop
|
||||||
|
|
||||||
|
# ESLint linting
|
||||||
|
- run:
|
||||||
|
name: eslint
|
||||||
|
command: pnpm run eslint
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Run frontend tests (with coverage)
|
||||||
|
command: |
|
||||||
|
mkdir -p ~/build/coverage/frontend
|
||||||
|
pnpm run test:coverage
|
||||||
|
|
||||||
|
# Run backend tests
|
||||||
- run:
|
- run:
|
||||||
name: Run backend tests
|
name: Run backend tests
|
||||||
command: |
|
command: |
|
||||||
mkdir -p ~/tmp/test-results/rspec
|
mkdir -p ~/tmp/test-results/rspec
|
||||||
mkdir -p ~/tmp/test-artifacts
|
mkdir -p ~/tmp/test-artifacts
|
||||||
mkdir -p ~/build/coverage/backend
|
mkdir -p ~/build/coverage/backend
|
||||||
|
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||||
# Use round-robin distribution (same as GitHub Actions) for better test isolation
|
|
||||||
# This prevents tests with similar timing from being grouped on the same runner
|
|
||||||
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
|
||||||
TESTS=""
|
|
||||||
|
|
||||||
for i in "${!SPEC_FILES[@]}"; do
|
|
||||||
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
|
|
||||||
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||||
--format RspecJunitFormatter \
|
--format RspecJunitFormatter \
|
||||||
--out ~/tmp/test-results/rspec.xml \
|
--out ~/tmp/test-results/rspec.xml \
|
||||||
-- $TESTS
|
-- ${TESTFILES}
|
||||||
no_output_timeout: 30m
|
no_output_timeout: 30m
|
||||||
|
|
||||||
# Store test results for better splitting in future runs
|
# Qlty coverage publish
|
||||||
- store_test_results:
|
- qlty-orb/coverage_publish:
|
||||||
path: ~/tmp/test-results
|
files: |
|
||||||
|
coverage/coverage.json
|
||||||
|
coverage/lcov.info
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Move coverage files if they exist
|
name: List coverage directory contents
|
||||||
command: |
|
command: |
|
||||||
if [ -d "coverage" ]; then
|
ls -R ~/build/coverage
|
||||||
mkdir -p ~/build/coverage
|
|
||||||
cp -r coverage ~/build/coverage/backend || true
|
|
||||||
fi
|
|
||||||
when: always
|
|
||||||
|
|
||||||
- persist_to_workspace:
|
- persist_to_workspace:
|
||||||
root: ~/build
|
root: ~/build
|
||||||
paths:
|
paths:
|
||||||
- coverage
|
- coverage
|
||||||
|
|
||||||
# Collect coverage from all jobs
|
|
||||||
coverage:
|
|
||||||
<<: *defaults
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
- attach_workspace:
|
|
||||||
at: ~/build
|
|
||||||
|
|
||||||
# Qlty coverage publish
|
|
||||||
- qlty-orb/coverage_publish:
|
|
||||||
files: |
|
|
||||||
coverage/frontend/lcov.info
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: List coverage directory contents
|
|
||||||
command: |
|
|
||||||
ls -R ~/build/coverage || echo "No coverage directory"
|
|
||||||
|
|
||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: coverage
|
path: coverage
|
||||||
destination: coverage
|
destination: coverage
|
||||||
|
|
||||||
build:
|
|
||||||
<<: *defaults
|
|
||||||
steps:
|
|
||||||
- run:
|
|
||||||
name: Legacy build aggregator
|
|
||||||
command: |
|
|
||||||
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
|
||||||
|
|
||||||
workflows:
|
|
||||||
version: 2
|
|
||||||
build:
|
|
||||||
jobs:
|
|
||||||
- lint
|
|
||||||
- frontend-tests
|
|
||||||
- backend-tests
|
|
||||||
- coverage:
|
|
||||||
requires:
|
|
||||||
- frontend-tests
|
|
||||||
- backend-tests
|
|
||||||
- build:
|
|
||||||
requires:
|
|
||||||
- lint
|
|
||||||
- coverage
|
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ MAILER_INBOUND_EMAIL_DOMAIN=
|
|||||||
# mandrill for Mandrill
|
# mandrill for Mandrill
|
||||||
# postmark for Postmark
|
# postmark for Postmark
|
||||||
# sendgrid for Sendgrid
|
# sendgrid for Sendgrid
|
||||||
# ses for Amazon SES
|
|
||||||
RAILS_INBOUND_EMAIL_SERVICE=
|
RAILS_INBOUND_EMAIL_SERVICE=
|
||||||
# Use one of the following based on the email ingress service
|
# Use one of the following based on the email ingress service
|
||||||
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
||||||
@@ -115,10 +114,6 @@ RAILS_INBOUND_EMAIL_PASSWORD=
|
|||||||
MAILGUN_INGRESS_SIGNING_KEY=
|
MAILGUN_INGRESS_SIGNING_KEY=
|
||||||
MANDRILL_INGRESS_API_KEY=
|
MANDRILL_INGRESS_API_KEY=
|
||||||
|
|
||||||
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
|
|
||||||
# Configure only if the rails_inbound_email_service = ses
|
|
||||||
ACTION_MAILBOX_SES_SNS_TOPIC=
|
|
||||||
|
|
||||||
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
||||||
# Inbound webhook URL format:
|
# Inbound webhook URL format:
|
||||||
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
||||||
@@ -220,7 +215,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
|||||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||||
# DD_TRACE_AGENT_URL=
|
# DD_TRACE_AGENT_URL=
|
||||||
|
|
||||||
|
|
||||||
# MaxMindDB API key to download GeoLite2 City database
|
# MaxMindDB API key to download GeoLite2 City database
|
||||||
# IP_LOOKUP_API_KEY=
|
# IP_LOOKUP_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
name: Run Chatwoot CE spec
|
name: Run Chatwoot CE spec
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -10,58 +8,11 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Separate linting jobs for faster feedback
|
test:
|
||||||
lint-backend:
|
runs-on: ubuntu-22.04
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
bundler-cache: true
|
|
||||||
- name: Run Rubocop
|
|
||||||
run: bundle exec rubocop --parallel
|
|
||||||
|
|
||||||
lint-frontend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 23
|
|
||||||
cache: 'pnpm'
|
|
||||||
- name: Install pnpm dependencies
|
|
||||||
run: pnpm i
|
|
||||||
- name: Run ESLint
|
|
||||||
run: pnpm run eslint
|
|
||||||
|
|
||||||
# Frontend tests run in parallel with backend
|
|
||||||
frontend-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 23
|
|
||||||
cache: 'pnpm'
|
|
||||||
- name: Install pnpm dependencies
|
|
||||||
run: pnpm i
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: pnpm run test:coverage
|
|
||||||
|
|
||||||
# Backend tests with parallelization
|
|
||||||
backend-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
ci_node_total: [16]
|
|
||||||
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg15
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ''
|
POSTGRES_PASSWORD: ''
|
||||||
@@ -69,6 +20,8 @@ jobs:
|
|||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
# needed because the postgres container does not provide a healthcheck
|
||||||
|
# tmpfs makes DB faster by using RAM
|
||||||
options: >-
|
options: >-
|
||||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
@@ -76,7 +29,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: --entrypoint redis-server
|
options: --entrypoint redis-server
|
||||||
@@ -90,7 +43,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
bundler-cache: true
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -111,36 +64,19 @@ jobs:
|
|||||||
- name: Seed database
|
- name: Seed database
|
||||||
run: bundle exec rake db:schema:load
|
run: bundle exec rake db:schema:load
|
||||||
|
|
||||||
- name: Run backend tests (parallelized)
|
- name: Run frontend tests
|
||||||
|
run: pnpm run test:coverage
|
||||||
|
|
||||||
|
# Run rails tests
|
||||||
|
- name: Run backend tests
|
||||||
run: |
|
run: |
|
||||||
# Get all spec files and split them using round-robin distribution
|
bundle exec rspec --profile=10 --format documentation
|
||||||
# This ensures slow tests are distributed evenly across all nodes
|
|
||||||
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
|
||||||
TESTS=""
|
|
||||||
|
|
||||||
for i in "${!SPEC_FILES[@]}"; do
|
|
||||||
# Assign spec to this node if: index % total == node_index
|
|
||||||
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
|
|
||||||
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -n "$TESTS" ]; then
|
|
||||||
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
|
|
||||||
fi
|
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --openssl-legacy-provider
|
NODE_OPTIONS: --openssl-legacy-provider
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload rails log folder
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: rspec-results-${{ matrix.ci_node_index }}
|
name: rails-log-folder
|
||||||
path: tmp/rspec_results.json
|
|
||||||
|
|
||||||
- name: Upload rails log folder
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: rails-log-folder-${{ matrix.ci_node_index }}
|
|
||||||
path: log
|
path: log
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ jobs:
|
|||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: false
|
push: false
|
||||||
load: false
|
load: false
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -99,5 +99,3 @@ CLAUDE.local.md
|
|||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
.netlify
|
.netlify
|
||||||
.histoire
|
.histoire
|
||||||
.pnpm-store/*
|
|
||||||
local/
|
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ exclude_patterns = [
|
|||||||
"**/target/**",
|
"**/target/**",
|
||||||
"**/templates/**",
|
"**/templates/**",
|
||||||
"**/testdata/**",
|
"**/testdata/**",
|
||||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||||
]
|
]
|
||||||
|
|
||||||
test_patterns = [
|
test_patterns = [
|
||||||
|
|||||||
+1
-5
@@ -7,7 +7,6 @@ plugins:
|
|||||||
require:
|
require:
|
||||||
- ./rubocop/use_from_email.rb
|
- ./rubocop/use_from_email.rb
|
||||||
- ./rubocop/custom_cop_location.rb
|
- ./rubocop/custom_cop_location.rb
|
||||||
- ./rubocop/one_class_per_file.rb
|
|
||||||
|
|
||||||
Layout/LineLength:
|
Layout/LineLength:
|
||||||
Max: 150
|
Max: 150
|
||||||
@@ -88,7 +87,7 @@ Metrics/ModuleLength:
|
|||||||
Rails/HelperInstanceVariable:
|
Rails/HelperInstanceVariable:
|
||||||
Exclude:
|
Exclude:
|
||||||
- enterprise/app/helpers/captain/chat_helper.rb
|
- enterprise/app/helpers/captain/chat_helper.rb
|
||||||
- 'enterprise/app/helpers/captain/tool_execution_helper.rb'
|
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||||
@@ -206,9 +205,6 @@ UseFromEmail:
|
|||||||
CustomCopLocation:
|
CustomCopLocation:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
Style/OneClassPerFile:
|
|
||||||
Enabled: true
|
|
||||||
|
|
||||||
AllCops:
|
AllCops:
|
||||||
NewCops: enable
|
NewCops: enable
|
||||||
Exclude:
|
Exclude:
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||||
- **Run Project**: `overmind start -f Procfile.dev`
|
- **Run Project**: `overmind start -f Procfile.dev`
|
||||||
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
|
||||||
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
|
||||||
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -40,8 +37,6 @@
|
|||||||
|
|
||||||
- MVP focus: Least code change, happy-path only
|
- MVP focus: Least code change, happy-path only
|
||||||
- No unnecessary defensive programming
|
- No unnecessary defensive programming
|
||||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
|
||||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
|
||||||
- Break down complex tasks into small, testable units
|
- Break down complex tasks into small, testable units
|
||||||
- Iterate after confirmation
|
- Iterate after confirmation
|
||||||
- Avoid writing specs unless explicitly asked
|
- Avoid writing specs unless explicitly asked
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
|
|||||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||||
gem 'image_processing'
|
gem 'image_processing'
|
||||||
|
|
||||||
##-- for actionmailbox --##
|
|
||||||
gem 'aws-actionmailbox-ses', '~> 0'
|
|
||||||
|
|
||||||
##-- gems for database --#
|
##-- gems for database --#
|
||||||
gem 'groupdate'
|
gem 'groupdate'
|
||||||
gem 'pg'
|
gem 'pg'
|
||||||
@@ -196,10 +193,6 @@ gem 'ai-agents', '>= 0.4.3'
|
|||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
# OpenTelemetry for LLM observability
|
|
||||||
gem 'opentelemetry-sdk'
|
|
||||||
gem 'opentelemetry-exporter-otlp'
|
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
### Gems required only in specific deployment environments ###
|
### Gems required only in specific deployment environments ###
|
||||||
|
|||||||
+3
-32
@@ -136,13 +136,9 @@ GEM
|
|||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
activerecord (>= 5.0, < 7.7)
|
activerecord (>= 5.0, < 7.7)
|
||||||
activesupport (>= 5.0, < 7.7)
|
activesupport (>= 5.0, < 7.7)
|
||||||
aws-actionmailbox-ses (0.1.0)
|
|
||||||
actionmailbox (>= 7.1.0)
|
|
||||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
|
||||||
aws-sdk-sns (~> 1, >= 1.61.0)
|
|
||||||
aws-eventstream (1.2.0)
|
aws-eventstream (1.2.0)
|
||||||
aws-partitions (1.760.0)
|
aws-partitions (1.760.0)
|
||||||
aws-sdk-core (3.188.0)
|
aws-sdk-core (3.171.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
@@ -150,13 +146,10 @@ GEM
|
|||||||
aws-sdk-kms (1.64.0)
|
aws-sdk-kms (1.64.0)
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-core (~> 3, >= 3.165.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.126.0)
|
aws-sdk-s3 (1.122.0)
|
||||||
aws-sdk-core (~> 3, >= 3.174.0)
|
aws-sdk-core (~> 3, >= 3.165.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.4)
|
||||||
aws-sdk-sns (1.70.0)
|
|
||||||
aws-sdk-core (~> 3, >= 3.188.0)
|
|
||||||
aws-sigv4 (~> 1.1)
|
|
||||||
aws-sigv4 (1.5.2)
|
aws-sigv4 (1.5.2)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
@@ -625,25 +618,6 @@ GEM
|
|||||||
faraday (>= 1.0, < 3)
|
faraday (>= 1.0, < 3)
|
||||||
multi_json (>= 1.0)
|
multi_json (>= 1.0)
|
||||||
openssl (3.2.0)
|
openssl (3.2.0)
|
||||||
opentelemetry-api (1.7.0)
|
|
||||||
opentelemetry-common (0.23.0)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-exporter-otlp (0.31.1)
|
|
||||||
google-protobuf (>= 3.18)
|
|
||||||
googleapis-common-protos-types (~> 1.3)
|
|
||||||
opentelemetry-api (~> 1.1)
|
|
||||||
opentelemetry-common (~> 0.20)
|
|
||||||
opentelemetry-sdk (~> 1.10)
|
|
||||||
opentelemetry-semantic_conventions
|
|
||||||
opentelemetry-registry (0.4.0)
|
|
||||||
opentelemetry-api (~> 1.1)
|
|
||||||
opentelemetry-sdk (1.10.0)
|
|
||||||
opentelemetry-api (~> 1.1)
|
|
||||||
opentelemetry-common (~> 0.20)
|
|
||||||
opentelemetry-registry (~> 0.2)
|
|
||||||
opentelemetry-semantic_conventions
|
|
||||||
opentelemetry-semantic_conventions (1.36.0)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
@@ -1021,7 +995,6 @@ DEPENDENCIES
|
|||||||
annotate
|
annotate
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
aws-actionmailbox-ses (~> 0)
|
|
||||||
aws-sdk-s3
|
aws-sdk-s3
|
||||||
azure-storage-blob!
|
azure-storage-blob!
|
||||||
barnes
|
barnes
|
||||||
@@ -1092,8 +1065,6 @@ DEPENDENCIES
|
|||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
omniauth-saml
|
omniauth-saml
|
||||||
opensearch-ruby
|
opensearch-ruby
|
||||||
opentelemetry-exporter-otlp
|
|
||||||
opentelemetry-sdk
|
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
pgvector
|
pgvector
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.8.0
|
4.4.0
|
||||||
|
|||||||
@@ -103,5 +103,3 @@ class ContactInboxBuilder
|
|||||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
class Messages::MessageBuilder
|
class Messages::MessageBuilder
|
||||||
include ::FileTypeHelper
|
include ::FileTypeHelper
|
||||||
include ::EmailHelper
|
|
||||||
include ::DataHelper
|
|
||||||
|
|
||||||
attr_reader :message
|
attr_reader :message
|
||||||
|
|
||||||
def initialize(user, conversation, params)
|
def initialize(user, conversation, params)
|
||||||
@@ -41,12 +38,30 @@ class Messages::MessageBuilder
|
|||||||
params = convert_to_hash(@params)
|
params = convert_to_hash(@params)
|
||||||
content_attributes = params.fetch(:content_attributes, {})
|
content_attributes = params.fetch(:content_attributes, {})
|
||||||
|
|
||||||
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
|
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||||
return content_attributes if content_attributes.is_a?(Hash)
|
return content_attributes if content_attributes.is_a?(Hash)
|
||||||
|
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Converts the given object to a hash.
|
||||||
|
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
|
||||||
|
# Otherwise, returns the object as-is.
|
||||||
|
def convert_to_hash(obj)
|
||||||
|
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||||
|
|
||||||
|
obj
|
||||||
|
end
|
||||||
|
|
||||||
|
# Attempts to parse a string as JSON.
|
||||||
|
# If successful, returns the parsed hash with symbolized names.
|
||||||
|
# If unsuccessful, returns nil.
|
||||||
|
def parse_json(content)
|
||||||
|
JSON.parse(content, symbolize_names: true)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
def process_attachments
|
def process_attachments
|
||||||
return if @attachments.blank?
|
return if @attachments.blank?
|
||||||
|
|
||||||
@@ -95,6 +110,12 @@ class Messages::MessageBuilder
|
|||||||
email_string.gsub(/\s+/, '').split(',')
|
email_string.gsub(/\s+/, '').split(',')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_email_addresses(all_emails)
|
||||||
|
all_emails&.each do |email|
|
||||||
|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def message_type
|
def message_type
|
||||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||||
@@ -138,7 +159,6 @@ class Messages::MessageBuilder
|
|||||||
private: @private,
|
private: @private,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
content_attributes: content_attributes.presence,
|
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
@@ -158,17 +178,14 @@ class Messages::MessageBuilder
|
|||||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||||
normalized_content = normalize_email_body(@message.content)
|
normalized_content = normalize_email_body(@message.content)
|
||||||
|
|
||||||
# Process liquid templates in normalized content with code block protection
|
|
||||||
processed_content = process_liquid_in_email_body(normalized_content)
|
|
||||||
|
|
||||||
# Use custom HTML content if provided, otherwise generate from message content
|
# Use custom HTML content if provided, otherwise generate from message content
|
||||||
email_attributes[:html_content] = if custom_email_content_provided?
|
email_attributes[:html_content] = if custom_email_content_provided?
|
||||||
build_custom_html_content
|
build_custom_html_content
|
||||||
else
|
else
|
||||||
build_html_content(processed_content)
|
build_html_content(normalized_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
email_attributes[:text_content] = build_text_content(processed_content)
|
email_attributes[:text_content] = build_text_content(normalized_content)
|
||||||
email_attributes
|
email_attributes
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -187,6 +204,22 @@ class Messages::MessageBuilder
|
|||||||
text_content
|
text_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ensure_indifferent_access(hash)
|
||||||
|
return {} if hash.blank?
|
||||||
|
|
||||||
|
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_email_body(content)
|
||||||
|
content.to_s.gsub("\r\n", "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_email_html(content)
|
||||||
|
return '' if content.blank?
|
||||||
|
|
||||||
|
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||||
|
end
|
||||||
|
|
||||||
def custom_email_content_provided?
|
def custom_email_content_provided?
|
||||||
@params[:email_html_content].present?
|
@params[:email_html_content].present?
|
||||||
end
|
end
|
||||||
@@ -199,29 +232,4 @@ class Messages::MessageBuilder
|
|||||||
|
|
||||||
html_content
|
html_content
|
||||||
end
|
end
|
||||||
|
|
||||||
# Liquid processing methods for email content
|
|
||||||
def process_liquid_in_email_body(content)
|
|
||||||
return content if content.blank?
|
|
||||||
return content unless should_process_liquid?
|
|
||||||
|
|
||||||
# Protect code blocks from liquid processing
|
|
||||||
modified_content = modified_liquid_content(content)
|
|
||||||
template = Liquid::Template.parse(modified_content)
|
|
||||||
template.render(drops_with_sender)
|
|
||||||
rescue Liquid::Error
|
|
||||||
content
|
|
||||||
end
|
|
||||||
|
|
||||||
def should_process_liquid?
|
|
||||||
@message_type == 'outgoing' || @message_type == 'template'
|
|
||||||
end
|
|
||||||
|
|
||||||
def drops_with_sender
|
|
||||||
message_drops(@conversation).merge({
|
|
||||||
'agent' => UserDrop.new(sender)
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
before_action :agent_bot, except: [:index, :create]
|
before_action :agent_bot, except: [:index, :create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
|
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
|
||||||
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
def edit; end
|
def edit; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
params_with_defaults = article_params
|
@article = @portal.articles.create!(article_params)
|
||||||
params_with_defaults[:status] ||= :draft
|
|
||||||
@article = @portal.articles.create!(params_with_defaults)
|
|
||||||
@article.associate_root_article(article_params[:associated_article_id])
|
@article.associate_root_article(article_params[:associated_article_id])
|
||||||
|
@article.draft!
|
||||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
||||||
# assigns agent/team to a conversation
|
# assigns agent/team to a conversation
|
||||||
def create
|
def create
|
||||||
if params.key?(:assignee_id) || agent_bot_assignment?
|
if params.key?(:assignee_id)
|
||||||
set_agent
|
set_agent
|
||||||
elsif params.key?(:team_id)
|
elsif params.key?(:team_id)
|
||||||
set_team
|
set_team
|
||||||
@@ -13,23 +13,17 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_agent
|
def set_agent
|
||||||
resource = Conversations::AssignmentService.new(
|
@agent = Current.account.users.find_by(id: params[:assignee_id])
|
||||||
conversation: @conversation,
|
@conversation.assignee = @agent
|
||||||
assignee_id: params[:assignee_id],
|
@conversation.save!
|
||||||
assignee_type: params[:assignee_type]
|
render_agent
|
||||||
).perform
|
|
||||||
|
|
||||||
render_agent(resource)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_agent(resource)
|
def render_agent
|
||||||
case resource
|
if @agent.nil?
|
||||||
when User
|
|
||||||
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
|
|
||||||
when AgentBot
|
|
||||||
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
|
|
||||||
else
|
|
||||||
render json: nil
|
render json: nil
|
||||||
|
else
|
||||||
|
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -38,8 +32,4 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
|||||||
@conversation.update!(team: @team)
|
@conversation.update!(team: @team)
|
||||||
render json: @team
|
render json: @team
|
||||||
end
|
end
|
||||||
|
|
||||||
def agent_bot_assignment?
|
|
||||||
params[:assignee_type].to_s == 'AgentBot'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def webhook_params
|
def webhook_params
|
||||||
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_webhook
|
def fetch_webhook
|
||||||
|
|||||||
@@ -9,13 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(
|
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
|
||||||
'LOGO_THUMBNAIL',
|
|
||||||
'BRAND_NAME',
|
|
||||||
'WIDGET_BRAND_URL',
|
|
||||||
'MAXIMUM_FILE_UPLOAD_SIZE',
|
|
||||||
'INSTALLATION_NAME'
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_contact
|
def set_contact
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ module EnsureCurrentAccountHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def account_accessible_for_bot?(account)
|
def account_accessible_for_bot?(account)
|
||||||
return if @resource.account_id == account.id
|
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||||
return if @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
|
||||||
|
|
||||||
render_unauthorized('Bot is not authorized to access this account')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,31 +1,6 @@
|
|||||||
class DashboardController < ActionController::Base
|
class DashboardController < ActionController::Base
|
||||||
include SwitchLocale
|
include SwitchLocale
|
||||||
|
|
||||||
GLOBAL_CONFIG_KEYS = %w[
|
|
||||||
LOGO
|
|
||||||
LOGO_DARK
|
|
||||||
LOGO_THUMBNAIL
|
|
||||||
INSTALLATION_NAME
|
|
||||||
WIDGET_BRAND_URL
|
|
||||||
TERMS_URL
|
|
||||||
BRAND_URL
|
|
||||||
BRAND_NAME
|
|
||||||
PRIVACY_URL
|
|
||||||
DISPLAY_MANIFEST
|
|
||||||
CREATE_NEW_ACCOUNT_FROM_DASHBOARD
|
|
||||||
CHATWOOT_INBOX_TOKEN
|
|
||||||
API_CHANNEL_NAME
|
|
||||||
API_CHANNEL_THUMBNAIL
|
|
||||||
ANALYTICS_TOKEN
|
|
||||||
DIRECT_UPLOADS_ENABLED
|
|
||||||
MAXIMUM_FILE_UPLOAD_SIZE
|
|
||||||
HCAPTCHA_SITE_KEY
|
|
||||||
LOGOUT_REDIRECT_LINK
|
|
||||||
DISABLE_USER_PROFILE_UPDATE
|
|
||||||
DEPLOYMENT_ENV
|
|
||||||
INSTALLATION_PRICING_PLAN
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
before_action :set_application_pack
|
before_action :set_application_pack
|
||||||
before_action :set_global_config
|
before_action :set_global_config
|
||||||
before_action :set_dashboard_scripts
|
before_action :set_dashboard_scripts
|
||||||
@@ -44,7 +19,25 @@ class DashboardController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config)
|
@global_config = GlobalConfig.get(
|
||||||
|
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
|
||||||
|
'INSTALLATION_NAME',
|
||||||
|
'WIDGET_BRAND_URL', 'TERMS_URL',
|
||||||
|
'BRAND_URL', 'BRAND_NAME',
|
||||||
|
'PRIVACY_URL',
|
||||||
|
'DISPLAY_MANIFEST',
|
||||||
|
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
||||||
|
'CHATWOOT_INBOX_TOKEN',
|
||||||
|
'API_CHANNEL_NAME',
|
||||||
|
'API_CHANNEL_THUMBNAIL',
|
||||||
|
'ANALYTICS_TOKEN',
|
||||||
|
'DIRECT_UPLOADS_ENABLED',
|
||||||
|
'HCAPTCHA_SITE_KEY',
|
||||||
|
'LOGOUT_REDIRECT_LINK',
|
||||||
|
'DISABLE_USER_PROFILE_UPDATE',
|
||||||
|
'DEPLOYMENT_ENV',
|
||||||
|
'INSTALLATION_PRICING_PLAN'
|
||||||
|
).merge(app_config)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_dashboard_scripts
|
def set_dashboard_scripts
|
||||||
@@ -78,18 +71,10 @@ class DashboardController < ActionController::Base
|
|||||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||||
GIT_SHA: GIT_HASH,
|
GIT_SHA: GIT_HASH
|
||||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def allowed_login_methods
|
|
||||||
methods = ['email']
|
|
||||||
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
|
||||||
methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false'
|
|
||||||
methods
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_application_pack
|
def set_application_pack
|
||||||
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
||||||
'v3app'
|
'v3app'
|
||||||
|
|||||||
@@ -15,20 +15,14 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
errors = []
|
|
||||||
params['app_config'].each do |key, value|
|
params['app_config'].each do |key, value|
|
||||||
next unless @allowed_configs.include?(key)
|
next unless @allowed_configs.include?(key)
|
||||||
|
|
||||||
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
||||||
i.value = value
|
i.value = value
|
||||||
errors.concat(i.errors.full_messages) unless i.save
|
i.save!
|
||||||
end
|
|
||||||
|
|
||||||
if errors.any?
|
|
||||||
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
|
||||||
else
|
|
||||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
|
||||||
end
|
end
|
||||||
|
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -48,13 +42,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
|
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
|
||||||
}
|
}
|
||||||
|
|
||||||
@allowed_configs = mapping.fetch(
|
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||||
@config,
|
|
||||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,7 @@ class WidgetsController < ActionController::Base
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(
|
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
|
||||||
'LOGO_THUMBNAIL',
|
|
||||||
'BRAND_NAME',
|
|
||||||
'WIDGET_BRAND_URL',
|
|
||||||
'DIRECT_UPLOADS_ENABLED',
|
|
||||||
'MAXIMUM_FILE_UPLOAD_SIZE',
|
|
||||||
'INSTALLATION_NAME'
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_web_widget
|
def set_web_widget
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
|
|
||||||
# This module contains helper methods for converting between different data types,
|
|
||||||
# normalizing hashes, and safely handling JSON operations.
|
|
||||||
module DataHelper
|
|
||||||
# Ensures a hash supports indifferent access (string or symbol keys).
|
|
||||||
# Returns an empty hash if the input is blank.
|
|
||||||
def ensure_indifferent_access(hash)
|
|
||||||
return {} if hash.blank?
|
|
||||||
|
|
||||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_to_hash(obj)
|
|
||||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
|
||||||
|
|
||||||
obj
|
|
||||||
end
|
|
||||||
|
|
||||||
def safe_parse_json(content)
|
|
||||||
JSON.parse(content, symbolize_names: true)
|
|
||||||
rescue JSON::ParserError
|
|
||||||
{}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -4,19 +4,6 @@ module EmailHelper
|
|||||||
domain.split('.').first
|
domain.split('.').first
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_email_html(content)
|
|
||||||
return '' if content.blank?
|
|
||||||
|
|
||||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
# Raise a standard error if any email address is invalid
|
|
||||||
def validate_email_addresses(emails_to_test)
|
|
||||||
emails_to_test&.each do |email|
|
|
||||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
||||||
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
||||||
# john+test@xyc.com is the same as john@xyc.com
|
# john+test@xyc.com is the same as john@xyc.com
|
||||||
@@ -34,10 +21,6 @@ module EmailHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalize_email_body(content)
|
|
||||||
content.to_s.gsub("\r\n", "\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
def modified_liquid_content(email)
|
def modified_liquid_content(email)
|
||||||
# This regex is used to match the code blocks in the content
|
# This regex is used to match the code blocks in the content
|
||||||
# We don't want to process liquid in code blocks
|
# We don't want to process liquid in code blocks
|
||||||
@@ -46,10 +29,7 @@ module EmailHelper
|
|||||||
|
|
||||||
def message_drops(conversation)
|
def message_drops(conversation)
|
||||||
{
|
{
|
||||||
'contact' => ContactDrop.new(conversation.contact),
|
'contact' => ContactDrop.new(conversation.contact)
|
||||||
'conversation' => ConversationDrop.new(conversation),
|
|
||||||
'inbox' => InboxDrop.new(conversation.inbox),
|
|
||||||
'account' => AccountDrop.new(conversation.account)
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ captain:
|
|||||||
icon: 'icon-captain'
|
icon: 'icon-captain'
|
||||||
config_key: 'captain'
|
config_key: 'captain'
|
||||||
enterprise: true
|
enterprise: true
|
||||||
saml:
|
|
||||||
name: 'SAML SSO'
|
|
||||||
description: 'Configuration for controlling SAML Single Sign-On availability'
|
|
||||||
enabled: <%= ChatwootApp.enterprise? %>
|
|
||||||
icon: 'icon-lock-line'
|
|
||||||
config_key: 'saml'
|
|
||||||
enterprise: true
|
|
||||||
custom_branding:
|
custom_branding:
|
||||||
name: 'Custom Branding'
|
name: 'Custom Branding'
|
||||||
description: 'Apply your own branding to this installation.'
|
description: 'Apply your own branding to this installation.'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
import AddAccountModal from './components/app/AddAccountModal.vue';
|
||||||
import LoadingState from './components/widgets/LoadingState.vue';
|
import LoadingState from './components/widgets/LoadingState.vue';
|
||||||
import NetworkNotification from './components/NetworkNotification.vue';
|
import NetworkNotification from './components/NetworkNotification.vue';
|
||||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||||
@@ -24,6 +25,7 @@ export default {
|
|||||||
name: 'App',
|
name: 'App',
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
|
AddAccountModal,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
NetworkNotification,
|
NetworkNotification,
|
||||||
UpdateBanner,
|
UpdateBanner,
|
||||||
@@ -49,6 +51,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
showAddAccountModal: false,
|
||||||
latestChatwootVersion: null,
|
latestChatwootVersion: null,
|
||||||
reconnectService: null,
|
reconnectService: null,
|
||||||
};
|
};
|
||||||
@@ -61,12 +64,21 @@ export default {
|
|||||||
authUIFlags: 'getAuthUIFlags',
|
authUIFlags: 'getAuthUIFlags',
|
||||||
accountUIFlags: 'accounts/getUIFlags',
|
accountUIFlags: 'accounts/getUIFlags',
|
||||||
}),
|
}),
|
||||||
|
hasAccounts() {
|
||||||
|
const { accounts = [] } = this.currentUser || {};
|
||||||
|
return accounts.length > 0;
|
||||||
|
},
|
||||||
hideOnOnboardingView() {
|
hideOnOnboardingView() {
|
||||||
return !isOnOnboardingView(this.$route);
|
return !isOnOnboardingView(this.$route);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
|
currentUser() {
|
||||||
|
if (!this.hasAccounts) {
|
||||||
|
this.showAddAccountModal = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
currentAccountId: {
|
currentAccountId: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler() {
|
handler() {
|
||||||
@@ -144,6 +156,7 @@ export default {
|
|||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
|
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||||
<WootSnackbarBox />
|
<WootSnackbarBox />
|
||||||
<NetworkNotification />
|
<NetworkNotification />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from './ApiClient';
|
|
||||||
|
|
||||||
export const buildCompanyParams = (page, sort) => {
|
|
||||||
let params = `page=${page}`;
|
|
||||||
if (sort) {
|
|
||||||
params = `${params}&sort=${sort}`;
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildSearchParams = (query, page, sort) => {
|
|
||||||
let params = `q=${encodeURIComponent(query)}&page=${page}`;
|
|
||||||
if (sort) {
|
|
||||||
params = `${params}&sort=${sort}`;
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
};
|
|
||||||
|
|
||||||
class CompanyAPI extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('companies', { accountScoped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
get(params = {}) {
|
|
||||||
const { page = 1, sort = 'name' } = params;
|
|
||||||
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
|
|
||||||
return axios.get(requestURL);
|
|
||||||
}
|
|
||||||
|
|
||||||
search(query = '', page = 1, sort = 'name') {
|
|
||||||
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
|
|
||||||
return axios.get(requestURL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new CompanyAPI();
|
|
||||||
@@ -47,12 +47,6 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/labels`);
|
return axios.get(`${this.url}/${contactId}/labels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
initiateCall(contactId, inboxId) {
|
|
||||||
return axios.post(`${this.url}/${contactId}/call`, {
|
|
||||||
inbox_id: inboxId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateContactLabels(contactId, labels) {
|
updateContactLabels(contactId, labels) {
|
||||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
super('', { accountScoped: true, enterprise: true });
|
super('', { accountScoped: true, enterprise: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// V1 endpoints
|
|
||||||
checkout() {
|
checkout() {
|
||||||
return axios.post(`${this.url}checkout`);
|
return axios.post(`${this.url}checkout`);
|
||||||
}
|
}
|
||||||
@@ -24,49 +23,6 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
action_type: action,
|
action_type: action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// V2 Billing endpoints
|
|
||||||
get v2BillingUrl() {
|
|
||||||
const accountId = this.accountIdFromRoute;
|
|
||||||
return `/enterprise/api/v2/accounts/${accountId}/billing`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCreditGrants() {
|
|
||||||
return axios.get(`${this.v2BillingUrl}/credit_grants`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getPricingPlans() {
|
|
||||||
return axios.get(`${this.v2BillingUrl}/pricing_plans`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTopupOptions() {
|
|
||||||
return axios.get(`${this.v2BillingUrl}/topup_options`);
|
|
||||||
}
|
|
||||||
|
|
||||||
topupCredits(credits) {
|
|
||||||
return axios.post(`${this.v2BillingUrl}/topup`, { credits });
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeToPlan(pricingPlanId, quantity) {
|
|
||||||
return axios.post(`${this.v2BillingUrl}/subscribe`, {
|
|
||||||
pricing_plan_id: pricingPlanId,
|
|
||||||
quantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelSubscription(reason = null, feedback = null) {
|
|
||||||
return axios.post(`${this.v2BillingUrl}/cancel_subscription`, {
|
|
||||||
reason,
|
|
||||||
feedback,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
changePricingPlan(pricingPlanId, quantity) {
|
|
||||||
return axios.post(`${this.v2BillingUrl}/change_pricing_plan`, {
|
|
||||||
pricing_plan_id: pricingPlanId,
|
|
||||||
quantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ class ConversationApi extends ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assignAgent({ conversationId, agentId }) {
|
assignAgent({ conversationId, agentId }) {
|
||||||
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
return axios.post(
|
||||||
assignee_id: agentId,
|
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
|
||||||
});
|
{}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assignTeam({ conversationId, teamId }) {
|
assignTeam({ conversationId, teamId }) {
|
||||||
|
|||||||
@@ -57,12 +57,6 @@ class OpenAIAPI extends ApiClient {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always include conversation_display_id when available for session tracking
|
|
||||||
if (conversationId) {
|
|
||||||
data.conversation_display_id = conversationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For conversation-level events, only send conversation_display_id
|
|
||||||
if (this.conversation_events.includes(type)) {
|
if (this.conversation_events.includes(type)) {
|
||||||
data = {
|
data = {
|
||||||
conversation_display_id: conversationId,
|
conversation_display_id: conversationId,
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import companyAPI, {
|
|
||||||
buildCompanyParams,
|
|
||||||
buildSearchParams,
|
|
||||||
} from '../companies';
|
|
||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
describe('#CompanyAPI', () => {
|
|
||||||
it('creates correct instance', () => {
|
|
||||||
expect(companyAPI).toBeInstanceOf(ApiClient);
|
|
||||||
expect(companyAPI).toHaveProperty('get');
|
|
||||||
expect(companyAPI).toHaveProperty('show');
|
|
||||||
expect(companyAPI).toHaveProperty('create');
|
|
||||||
expect(companyAPI).toHaveProperty('update');
|
|
||||||
expect(companyAPI).toHaveProperty('delete');
|
|
||||||
expect(companyAPI).toHaveProperty('search');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API calls', () => {
|
|
||||||
const originalAxios = window.axios;
|
|
||||||
const axiosMock = {
|
|
||||||
post: vi.fn(() => Promise.resolve()),
|
|
||||||
get: vi.fn(() => Promise.resolve()),
|
|
||||||
patch: vi.fn(() => Promise.resolve()),
|
|
||||||
delete: vi.fn(() => Promise.resolve()),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
window.axios = axiosMock;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.axios = originalAxios;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#get with default params', () => {
|
|
||||||
companyAPI.get({});
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies?page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#get with page and sort params', () => {
|
|
||||||
companyAPI.get({ page: 2, sort: 'domain' });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies?page=2&sort=domain'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#get with descending sort', () => {
|
|
||||||
companyAPI.get({ page: 1, sort: '-created_at' });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies?page=1&sort=-created_at'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with query', () => {
|
|
||||||
companyAPI.search('acme', 1, 'name');
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies/search?q=acme&page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with special characters in query', () => {
|
|
||||||
companyAPI.search('acme & co', 2, 'domain');
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with descending sort', () => {
|
|
||||||
companyAPI.search('test', 1, '-created_at');
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with empty query', () => {
|
|
||||||
companyAPI.search('', 1, 'name');
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies/search?q=&page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#buildCompanyParams', () => {
|
|
||||||
it('returns correct string with page only', () => {
|
|
||||||
expect(buildCompanyParams(1)).toBe('page=1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with page and sort', () => {
|
|
||||||
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with different page', () => {
|
|
||||||
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with descending sort', () => {
|
|
||||||
expect(buildCompanyParams(1, '-created_at')).toBe(
|
|
||||||
'page=1&sort=-created_at'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string without sort parameter', () => {
|
|
||||||
expect(buildCompanyParams(2, '')).toBe('page=2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#buildSearchParams', () => {
|
|
||||||
it('returns correct string with all parameters', () => {
|
|
||||||
expect(buildSearchParams('acme', 1, 'name')).toBe(
|
|
||||||
'q=acme&page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with special characters', () => {
|
|
||||||
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
|
|
||||||
'q=acme%20%26%20co&page=2&sort=domain'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with empty query', () => {
|
|
||||||
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string without sort parameter', () => {
|
|
||||||
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with descending sort', () => {
|
|
||||||
expect(buildSearchParams('company', 3, '-created_at')).toBe(
|
|
||||||
'q=company&page=3&sort=-created_at'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('encodes special characters correctly', () => {
|
|
||||||
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
|
|
||||||
'q=test%40example.com&page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -92,10 +92,8 @@ describe('#ConversationAPI', () => {
|
|||||||
it('#assignAgent', () => {
|
it('#assignAgent', () => {
|
||||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
`/api/v1/conversations/12/assignments`,
|
`/api/v1/conversations/12/assignments?assignee_id=34`,
|
||||||
{
|
{}
|
||||||
assignee_id: 34,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
|
|
||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
id: { type: Number, required: true },
|
|
||||||
name: { type: String, default: '' },
|
|
||||||
domain: { type: String, default: '' },
|
|
||||||
contactsCount: { type: Number, default: 0 },
|
|
||||||
description: { type: String, default: '' },
|
|
||||||
avatarUrl: { type: String, default: '' },
|
|
||||||
updatedAt: { type: [String, Number], default: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['showCompany']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const onClickViewDetails = () => emit('showCompany', props.id);
|
|
||||||
|
|
||||||
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
|
|
||||||
|
|
||||||
const avatarSource = computed(() => props.avatarUrl || null);
|
|
||||||
|
|
||||||
const formattedUpdatedAt = computed(() => {
|
|
||||||
if (!props.updatedAt) return '';
|
|
||||||
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CardLayout layout="row" @click="onClickViewDetails">
|
|
||||||
<div class="flex items-center justify-start flex-1 gap-4">
|
|
||||||
<Avatar
|
|
||||||
:username="displayName"
|
|
||||||
:src="avatarSource"
|
|
||||||
class="shrink-0"
|
|
||||||
:name="name"
|
|
||||||
:size="48"
|
|
||||||
hide-offline-status
|
|
||||||
rounded-full
|
|
||||||
/>
|
|
||||||
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
|
||||||
<span class="text-base font-medium truncate text-n-slate-12">
|
|
||||||
{{ displayName }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="domain && description"
|
|
||||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
|
||||||
>
|
|
||||||
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
|
||||||
<span class="truncate">{{ domain }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
|
||||||
<span
|
|
||||||
v-if="domain && !description"
|
|
||||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
|
||||||
>
|
|
||||||
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
|
||||||
<span class="truncate">{{ domain }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="description" class="text-sm text-n-slate-11 truncate">
|
|
||||||
{{ description }}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
v-if="(description || domain) && contactsCount"
|
|
||||||
class="w-px h-3 bg-n-slate-6"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-if="contactsCount"
|
|
||||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
|
||||||
>
|
|
||||||
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
|
||||||
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-if="updatedAt"
|
|
||||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
|
|
||||||
>
|
|
||||||
{{ formattedUpdatedAt }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import CompanySortMenu from './components/CompanySortMenu.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
showSearch: { type: Boolean, default: true },
|
|
||||||
searchValue: { type: String, default: '' },
|
|
||||||
headerTitle: { type: String, required: true },
|
|
||||||
activeSort: { type: String, default: 'last_activity_at' },
|
|
||||||
activeOrdering: { type: String, default: '' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['search', 'update:sort']);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<header class="sticky top-0 z-10">
|
|
||||||
<div
|
|
||||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
|
||||||
>
|
|
||||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
|
||||||
{{ headerTitle }}
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<CompanySortMenu
|
|
||||||
:active-sort="activeSort"
|
|
||||||
:active-ordering="activeOrdering"
|
|
||||||
@update:sort="emit('update:sort', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
|
||||||
<Input
|
|
||||||
:model-value="searchValue"
|
|
||||||
type="search"
|
|
||||||
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
|
|
||||||
:custom-input-class="[
|
|
||||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
|
||||||
]"
|
|
||||||
class="w-full"
|
|
||||||
@input="emit('search', $event.target.value)"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Icon
|
|
||||||
icon="i-lucide-search"
|
|
||||||
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
-116
@@ -1,116 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, toRef } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
activeSort: {
|
|
||||||
type: String,
|
|
||||||
default: 'name',
|
|
||||||
},
|
|
||||||
activeOrdering: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:sort']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const isMenuOpen = ref(false);
|
|
||||||
|
|
||||||
const sortMenus = [
|
|
||||||
{
|
|
||||||
label: t('COMPANIES.SORT_BY.OPTIONS.NAME'),
|
|
||||||
value: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('COMPANIES.SORT_BY.OPTIONS.DOMAIN'),
|
|
||||||
value: 'domain',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
|
||||||
value: 'created_at',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const orderingMenus = [
|
|
||||||
{
|
|
||||||
label: t('COMPANIES.ORDER.OPTIONS.ASCENDING'),
|
|
||||||
value: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('COMPANIES.ORDER.OPTIONS.DESCENDING'),
|
|
||||||
value: '-',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Converted the props to refs for better reactivity
|
|
||||||
const activeSort = toRef(props, 'activeSort');
|
|
||||||
|
|
||||||
const activeOrdering = toRef(props, 'activeOrdering');
|
|
||||||
|
|
||||||
const activeSortLabel = computed(() => {
|
|
||||||
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
|
|
||||||
return selectedMenu?.label || t('COMPANIES.SORT_BY.LABEL');
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeOrderingLabel = computed(() => {
|
|
||||||
const selectedMenu = orderingMenus.find(
|
|
||||||
menu => menu.value === activeOrdering.value
|
|
||||||
);
|
|
||||||
return selectedMenu?.label || t('COMPANIES.ORDER.LABEL');
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSortChange = value => {
|
|
||||||
emit('update:sort', { sort: value, order: props.activeOrdering });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOrderChange = value => {
|
|
||||||
emit('update:sort', { sort: props.activeSort, order: value });
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-arrow-down-up"
|
|
||||||
color="slate"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
|
|
||||||
@click="isMenuOpen = !isMenuOpen"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="isMenuOpen"
|
|
||||||
v-on-clickaway="() => (isMenuOpen = false)"
|
|
||||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="text-sm text-n-slate-12">
|
|
||||||
{{ t('COMPANIES.SORT_BY.LABEL') }}
|
|
||||||
</span>
|
|
||||||
<SelectMenu
|
|
||||||
:model-value="activeSort"
|
|
||||||
:options="sortMenus"
|
|
||||||
:label="activeSortLabel"
|
|
||||||
@update:model-value="handleSortChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="text-sm text-n-slate-12">
|
|
||||||
{{ t('COMPANIES.ORDER.LABEL') }}
|
|
||||||
</span>
|
|
||||||
<SelectMenu
|
|
||||||
:model-value="activeOrdering"
|
|
||||||
:options="orderingMenus"
|
|
||||||
:label="activeOrderingLabel"
|
|
||||||
@update:model-value="handleOrderChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import CompanyHeader from './CompaniesHeader/CompanyHeader.vue';
|
|
||||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
searchValue: { type: String, default: '' },
|
|
||||||
headerTitle: { type: String, default: '' },
|
|
||||||
currentPage: { type: Number, default: 1 },
|
|
||||||
totalItems: { type: Number, default: 100 },
|
|
||||||
activeSort: { type: String, default: 'name' },
|
|
||||||
activeOrdering: { type: String, default: '' },
|
|
||||||
showPaginationFooter: { type: Boolean, default: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
|
|
||||||
|
|
||||||
const updateCurrentPage = page => {
|
|
||||||
emit('update:currentPage', page);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section
|
|
||||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
|
||||||
<CompanyHeader
|
|
||||||
:search-value="searchValue"
|
|
||||||
:header-title="headerTitle"
|
|
||||||
:active-sort="activeSort"
|
|
||||||
:active-ordering="activeOrdering"
|
|
||||||
@search="emit('search', $event)"
|
|
||||||
@update:sort="emit('update:sort', $event)"
|
|
||||||
/>
|
|
||||||
<main class="flex-1 overflow-y-auto">
|
|
||||||
<div class="w-full mx-auto max-w-[60rem]">
|
|
||||||
<slot name="default" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
|
||||||
<PaginationFooter
|
|
||||||
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
|
||||||
:current-page="currentPage"
|
|
||||||
:total-items="totalItems"
|
|
||||||
:items-per-page="25"
|
|
||||||
@update:current-page="updateCurrentPage"
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -102,7 +102,6 @@ const closeMobileSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
<VoiceCallButton
|
<VoiceCallButton
|
||||||
:phone="selectedContact?.phoneNumber"
|
:phone="selectedContact?.phoneNumber"
|
||||||
:contact-id="contactId"
|
|
||||||
:label="$t('CONTACT_PANEL.CALL')"
|
:label="$t('CONTACT_PANEL.CALL')"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, useAttrs } from 'vue';
|
import { computed, ref, useAttrs } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
phone: { type: String, default: '' },
|
phone: { type: String, default: '' },
|
||||||
contactId: { type: [String, Number], required: true },
|
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
size: { type: String, default: 'sm' },
|
size: { type: String, default: 'sm' },
|
||||||
@@ -21,17 +18,10 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
|
||||||
|
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
|
||||||
|
|
||||||
const voiceInboxes = computed(() =>
|
const voiceInboxes = computed(() =>
|
||||||
(inboxesList.value || []).filter(
|
(inboxesList.value || []).filter(
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||||
@@ -42,51 +32,20 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
|||||||
// Unified behavior: hide when no phone
|
// Unified behavior: hide when no phone
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
const isInitiatingCall = computed(() => {
|
const dialogRef = ref(null);
|
||||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const navigateToConversation = conversationId => {
|
const onClick = () => {
|
||||||
const accountId = route.params.accountId;
|
|
||||||
if (conversationId && accountId) {
|
|
||||||
const path = frontendURL(
|
|
||||||
conversationUrl({
|
|
||||||
accountId,
|
|
||||||
id: conversationId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
router.push({ path });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startCall = async inboxId => {
|
|
||||||
if (isInitiatingCall.value) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await store.dispatch('contacts/initiateCall', {
|
|
||||||
contactId: props.contactId,
|
|
||||||
inboxId,
|
|
||||||
});
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
|
||||||
navigateToConversation(response?.conversation_id);
|
|
||||||
} catch (error) {
|
|
||||||
const apiError = error?.message;
|
|
||||||
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClick = async () => {
|
|
||||||
if (voiceInboxes.value.length > 1) {
|
if (voiceInboxes.value.length > 1) {
|
||||||
dialogRef.value?.open();
|
dialogRef.value?.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [inbox] = voiceInboxes.value;
|
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||||
await startCall(inbox.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickInbox = async inbox => {
|
const onPickInbox = () => {
|
||||||
|
// Placeholder until actual call wiring happens
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
await startCall(inbox.id);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -96,8 +55,6 @@ const onPickInbox = async inbox => {
|
|||||||
v-if="shouldRender"
|
v-if="shouldRender"
|
||||||
v-tooltip.top-end="tooltipLabel || null"
|
v-tooltip.top-end="tooltipLabel || null"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
:disabled="isInitiatingCall"
|
|
||||||
:is-loading="isInitiatingCall"
|
|
||||||
:label="label"
|
:label="label"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:size="size"
|
:size="size"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
@@ -56,17 +55,17 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ButtonGroup
|
<div
|
||||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
|
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
sm
|
sm
|
||||||
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
|
class="!rounded-full"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
|
'bg-n-alpha-2': isContactSidebarOpen,
|
||||||
}"
|
}"
|
||||||
icon="i-ph-user-bold"
|
icon="i-ph-user-bold"
|
||||||
@click="handleConversationSidebarToggle"
|
@click="handleConversationSidebarToggle"
|
||||||
@@ -76,14 +75,13 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
sm
|
class="!rounded-full"
|
||||||
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
|
|
||||||
:class="{
|
:class="{
|
||||||
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm':
|
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
||||||
isCopilotPanelOpen,
|
|
||||||
}"
|
}"
|
||||||
|
sm
|
||||||
icon="i-woot-captain"
|
icon="i-woot-captain"
|
||||||
@click="handleCopilotSidebarToggle"
|
@click="handleCopilotSidebarToggle"
|
||||||
/>
|
/>
|
||||||
</ButtonGroup>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ const props = defineProps({
|
|||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
enabledMenuOptions: { type: Array, default: () => [] },
|
enabledMenuOptions: { type: Array, default: () => [] },
|
||||||
enableCaptainTools: { type: Boolean, default: false },
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
signature: { type: String, default: '' },
|
|
||||||
allowSignature: { type: Boolean, default: false },
|
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
|
||||||
channelType: { type: String, default: '' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -104,10 +100,6 @@ watch(
|
|||||||
:enable-canned-responses="enableCannedResponses"
|
:enable-canned-responses="enableCannedResponses"
|
||||||
:enabled-menu-options="enabledMenuOptions"
|
:enabled-menu-options="enabledMenuOptions"
|
||||||
:enable-captain-tools="enableCaptainTools"
|
:enable-captain-tools="enableCaptainTools"
|
||||||
:signature="signature"
|
|
||||||
:allow-signature="allowSignature"
|
|
||||||
:send-with-signature="sendWithSignature"
|
|
||||||
:channel-type="channelType"
|
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
|
|||||||
+2
-5
@@ -14,7 +14,6 @@ import {
|
|||||||
} from 'dashboard/helper/portalHelper';
|
} from 'dashboard/helper/portalHelper';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
@@ -141,12 +140,11 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
:disabled="!articleId"
|
:disabled="!articleId"
|
||||||
@click="previewArticle"
|
@click="previewArticle"
|
||||||
/>
|
/>
|
||||||
<ButtonGroup class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||||
no-animation
|
|
||||||
:is-loading="isArticlePublishing"
|
:is-loading="isArticlePublishing"
|
||||||
:disabled="
|
:disabled="
|
||||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||||
@@ -161,7 +159,6 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!articleId"
|
:disabled="!articleId"
|
||||||
no-animation
|
|
||||||
class="ltr:rounded-l-none rtl:rounded-r-none"
|
class="ltr:rounded-l-none rtl:rounded-r-none"
|
||||||
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
||||||
/>
|
/>
|
||||||
@@ -173,7 +170,7 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
</div>
|
</div>
|
||||||
</ButtonGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const setSignature = () => {
|
|||||||
|
|
||||||
const toggleMessageSignature = () => {
|
const toggleMessageSignature = () => {
|
||||||
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
||||||
|
setSignature();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Added this watch to dynamically set signature on target inbox change.
|
// Added this watch to dynamically set signature on target inbox change.
|
||||||
|
|||||||
+8
-14
@@ -199,20 +199,16 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
|||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSignatureFromMessage = () => {
|
|
||||||
// Always remove the signature from message content when inbox/contact is removed
|
|
||||||
// to ensure no leftover signature content remains
|
|
||||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
|
||||||
? props.messageSignature
|
|
||||||
: extractTextFromMarkdown(props.messageSignature);
|
|
||||||
if (signatureToRemove) {
|
|
||||||
state.message = removeSignature(state.message, signatureToRemove);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTargetInbox = value => {
|
const removeTargetInbox = value => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
removeSignatureFromMessage();
|
// Remove the signature from message content
|
||||||
|
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
|
||||||
|
if (props.sendWithSignature) {
|
||||||
|
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||||
|
? props.messageSignature
|
||||||
|
: extractTextFromMarkdown(props.messageSignature);
|
||||||
|
state.message = removeSignature(state.message, signatureToRemove);
|
||||||
|
}
|
||||||
emit('updateTargetInbox', value);
|
emit('updateTargetInbox', value);
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
@@ -220,7 +216,6 @@ const removeTargetInbox = value => {
|
|||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
emit('clearSelectedContact');
|
emit('clearSelectedContact');
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
removeSignatureFromMessage();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickInsertEmoji = emoji => {
|
const onClickInsertEmoji = emoji => {
|
||||||
@@ -359,7 +354,6 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:has-attachments="state.attachedFiles.length > 0"
|
:has-attachments="state.attachedFiles.length > 0"
|
||||||
:channel-type="inboxChannelType"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const props = defineProps({
|
|||||||
hasAttachments: { type: Boolean, default: false },
|
hasAttachments: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
channelType: { type: String, default: '' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -91,10 +90,6 @@ const replaceText = async message => {
|
|||||||
"
|
"
|
||||||
enable-variables
|
enable-variables
|
||||||
:show-character-count="false"
|
:show-character-count="false"
|
||||||
:signature="messageSignature"
|
|
||||||
allow-signature
|
|
||||||
:send-with-signature="sendWithSignature"
|
|
||||||
:channel-type="channelType"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const props = defineProps({
|
|||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
trailingIcon: { type: Boolean, default: false },
|
trailingIcon: { type: Boolean, default: false },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
noAnimation: { type: Boolean, default: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
@@ -180,18 +179,12 @@ const STYLE_CONFIG = {
|
|||||||
md: 'text-sm font-medium',
|
md: 'text-sm font-medium',
|
||||||
lg: 'text-base',
|
lg: 'text-base',
|
||||||
},
|
},
|
||||||
clickAnimation: {
|
|
||||||
xs: 'active:enabled:scale-[0.97]',
|
|
||||||
sm: 'active:enabled:scale-[0.97]',
|
|
||||||
md: 'active:enabled:scale-[0.98]',
|
|
||||||
lg: 'active:enabled:scale-[0.98]',
|
|
||||||
},
|
|
||||||
justify: {
|
justify: {
|
||||||
start: 'justify-start',
|
start: 'justify-start',
|
||||||
center: 'justify-center',
|
center: 'justify-center',
|
||||||
end: 'justify-end',
|
end: 'justify-end',
|
||||||
},
|
},
|
||||||
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClasses = computed(() => {
|
const variantClasses = computed(() => {
|
||||||
@@ -228,12 +221,6 @@ const linkButtonClasses = computed(() => {
|
|||||||
|
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
const animationClasses = computed(() => {
|
|
||||||
return props.noAnimation
|
|
||||||
? ''
|
|
||||||
: STYLE_CONFIG.clickAnimation[computedSize.value];
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -243,7 +230,6 @@ const animationClasses = computed(() => {
|
|||||||
[STYLE_CONFIG.base]: true,
|
[STYLE_CONFIG.base]: true,
|
||||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||||
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
||||||
[animationClasses]: true,
|
|
||||||
[STYLE_CONFIG.justify[computedJustify]]: true,
|
[STYLE_CONFIG.justify[computedJustify]]: true,
|
||||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
noAnimation: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:class="
|
|
||||||
noAnimation
|
|
||||||
? ''
|
|
||||||
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
|
||||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
||||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
import AssistantSwitcher from 'dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue';
|
|
||||||
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentPage: {
|
currentPage: {
|
||||||
@@ -62,36 +56,11 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
showAssistantSwitcher: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const { shouldShowPaywall } = usePolicy();
|
const { shouldShowPaywall } = usePolicy();
|
||||||
|
|
||||||
const showAssistantSwitcherDropdown = ref(false);
|
|
||||||
const createAssistantDialogRef = ref(null);
|
|
||||||
|
|
||||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
|
||||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
|
||||||
|
|
||||||
const currentAssistantId = computed(() => route.params.assistantId);
|
|
||||||
const isFetchingAssistants = computed(() => uiFlags.value?.fetchingList);
|
|
||||||
|
|
||||||
const activeAssistantName = computed(() => {
|
|
||||||
return (
|
|
||||||
assistants.value?.find(
|
|
||||||
assistant => assistant.id === Number(currentAssistantId.value)
|
|
||||||
)?.name || t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const showPaywall = computed(() => {
|
const showPaywall = computed(() => {
|
||||||
return shouldShowPaywall(props.featureFlag);
|
return shouldShowPaywall(props.featureFlag);
|
||||||
});
|
});
|
||||||
@@ -103,15 +72,6 @@ const handleButtonClick = () => {
|
|||||||
const handlePageChange = event => {
|
const handlePageChange = event => {
|
||||||
emit('update:currentPage', event);
|
emit('update:currentPage', event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAssistantSwitcher = () => {
|
|
||||||
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateAssistant = () => {
|
|
||||||
showAssistantSwitcherDropdown.value = false;
|
|
||||||
createAssistantDialogRef.value.dialogRef.open();
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -121,83 +81,37 @@ const handleCreateAssistant = () => {
|
|||||||
<div
|
<div
|
||||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||||
>
|
>
|
||||||
<div class="flex gap-3 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<BackButton v-if="backUrl" :back-url="backUrl" />
|
<BackButton v-if="backUrl" :to="backUrl" />
|
||||||
<div
|
<slot name="headerTitle">
|
||||||
v-if="showAssistantSwitcher && !showPaywall"
|
<span class="text-xl font-medium text-n-slate-12">
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
v-if="!isFetchingAssistants"
|
|
||||||
class="text-xl font-medium truncate text-n-slate-12"
|
|
||||||
>
|
|
||||||
{{ activeAssistantName }}
|
|
||||||
</span>
|
|
||||||
<div class="relative group">
|
|
||||||
<OnClickOutside
|
|
||||||
@trigger="showAssistantSwitcherDropdown = false"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
size="xs"
|
|
||||||
:disabled="isFetchingAssistants"
|
|
||||||
:is-loading="isFetchingAssistants"
|
|
||||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
|
|
||||||
@click="toggleAssistantSwitcher"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AssistantSwitcher
|
|
||||||
v-if="showAssistantSwitcherDropdown"
|
|
||||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
|
||||||
@close="showAssistantSwitcherDropdown = false"
|
|
||||||
@create-assistant="handleCreateAssistant"
|
|
||||||
/>
|
|
||||||
</OnClickOutside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div
|
|
||||||
v-if="showAssistantSwitcher && !showPaywall && headerTitle"
|
|
||||||
class="w-0.5 h-4 rounded-2xl bg-n-weak"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-if="headerTitle"
|
|
||||||
class="text-xl font-medium text-n-slate-12"
|
|
||||||
>
|
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
</slot>
|
||||||
v-if="!isEmpty && showKnowMore"
|
<div
|
||||||
class="flex items-center gap-2"
|
v-if="!isEmpty && showKnowMore"
|
||||||
>
|
class="flex items-center gap-2"
|
||||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
>
|
||||||
<slot name="knowMore" />
|
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||||
</div>
|
<slot name="knowMore" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div
|
||||||
<slot name="search" />
|
v-if="!showPaywall && buttonLabel"
|
||||||
<div
|
v-on-clickaway="() => emit('close')"
|
||||||
v-if="!showPaywall && buttonLabel"
|
class="relative group/campaign-button"
|
||||||
v-on-clickaway="() => emit('close')"
|
>
|
||||||
class="relative group/captain-button"
|
<Policy :permissions="buttonPolicy">
|
||||||
>
|
<Button
|
||||||
<Policy :permissions="buttonPolicy">
|
:label="buttonLabel"
|
||||||
<Button
|
icon="i-lucide-plus"
|
||||||
:label="buttonLabel"
|
size="sm"
|
||||||
icon="i-lucide-plus"
|
class="group-hover/campaign-button:brightness-110"
|
||||||
size="sm"
|
@click="handleButtonClick"
|
||||||
class="group-hover/captain-button:brightness-110"
|
/>
|
||||||
@click="handleButtonClick"
|
</Policy>
|
||||||
/>
|
<slot name="action" />
|
||||||
</Policy>
|
|
||||||
<slot name="action" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="subHeader" />
|
<slot name="subHeader" />
|
||||||
@@ -230,6 +144,5 @@ const handleCreateAssistant = () => {
|
|||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
isFetching: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isEmpty: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
totalCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 100,
|
||||||
|
},
|
||||||
|
itemsPerPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 25,
|
||||||
|
},
|
||||||
|
showPaginationFooter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
breadcrumbItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:currentPage']);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handlePageChange = event => {
|
||||||
|
emit('update:currentPage', event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = item => {
|
||||||
|
router.push({
|
||||||
|
name: item.routeName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||||
|
>
|
||||||
|
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
||||||
|
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||||
|
</header>
|
||||||
|
<main class="flex gap-16 w-full flex-1 pb-16">
|
||||||
|
<section
|
||||||
|
v-if="$slots.body || $slots.emptyState || isFetching"
|
||||||
|
class="flex flex-col w-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isFetching"
|
||||||
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isEmpty">
|
||||||
|
<slot name="emptyState" />
|
||||||
|
</div>
|
||||||
|
<slot v-else name="body" />
|
||||||
|
</section>
|
||||||
|
<section v-if="$slots.controls" class="flex w-full">
|
||||||
|
<slot name="controls" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
|
||||||
|
<PaginationFooter
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-items="totalCount"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
@update:current-page="handlePageChange"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -76,11 +76,12 @@ const handleAction = ({ action, value }) => {
|
|||||||
<template>
|
<template>
|
||||||
<CardLayout>
|
<CardLayout>
|
||||||
<div class="flex justify-between w-full gap-1">
|
<div class="flex justify-between w-full gap-1">
|
||||||
<h6
|
<router-link
|
||||||
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
|
||||||
|
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||||
>
|
>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h6>
|
</router-link>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import MessageList from './MessageList.vue';
|
import MessageList from './MessageList.vue';
|
||||||
@@ -29,16 +29,6 @@ const resetConversation = () => {
|
|||||||
newMessage.value = '';
|
newMessage.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for assistant ID changes and reset conversation
|
|
||||||
watch(
|
|
||||||
() => assistantId,
|
|
||||||
(newId, oldId) => {
|
|
||||||
if (oldId && newId !== oldId) {
|
|
||||||
resetConversation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!newMessage.value.trim() || isLoading.value) return;
|
if (!newMessage.value.trim() || isLoading.value) return;
|
||||||
|
|
||||||
@@ -75,17 +65,16 @@ const sendMessage = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
|
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
|
||||||
>
|
>
|
||||||
<div class="mb-8 px-6">
|
<div class="mb-4">
|
||||||
<div class="flex justify-between items-center mb-1">
|
<div class="flex justify-between items-center mb-1">
|
||||||
<h3 class="text-lg font-medium">
|
<h3 class="text-lg font-medium">
|
||||||
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
||||||
</h3>
|
</h3>
|
||||||
<NextButton
|
<NextButton
|
||||||
ghost
|
ghost
|
||||||
sm
|
size="small"
|
||||||
slate
|
|
||||||
icon="i-lucide-rotate-ccw"
|
icon="i-lucide-rotate-ccw"
|
||||||
@click="resetConversation"
|
@click="resetConversation"
|
||||||
/>
|
/>
|
||||||
@@ -98,17 +87,17 @@ const sendMessage = async () => {
|
|||||||
<MessageList :messages="messages" :is-loading="isLoading" />
|
<MessageList :messages="messages" :is-loading="isLoading" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
|
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
|
||||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
<NextButton
|
<NextButton
|
||||||
ghost
|
ghost
|
||||||
sm
|
size="small"
|
||||||
:disabled="!newMessage.trim()"
|
:disabled="!newMessage.trim()"
|
||||||
icon="i-lucide-send"
|
icon="i-lucide-send"
|
||||||
@click="sendMessage"
|
@click="sendMessage"
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
|
|||||||
|
|
||||||
const getMessageStyle = sender =>
|
const getMessageStyle = sender =>
|
||||||
isUserMessage(sender)
|
isUserMessage(sender)
|
||||||
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
|
? 'bg-n-strong text-n-white'
|
||||||
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
|
: 'bg-n-solid-iris text-n-slate-12';
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@@ -49,10 +49,7 @@ watch(() => props.messages.length, scrollToBottom);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
|
||||||
ref="messageContainer"
|
|
||||||
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="(message, index) in messages"
|
v-for="(message, index) in messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -60,20 +57,15 @@ watch(() => props.messages.length, scrollToBottom);
|
|||||||
:class="getMessageAlignment(message.sender)"
|
:class="getMessageAlignment(message.sender)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
|
class="flex items-start gap-1.5"
|
||||||
:class="getMessageDirection(message.sender)"
|
:class="getMessageDirection(message.sender)"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
|
||||||
:name="getAvatarName(message.sender)"
|
|
||||||
rounded-full
|
|
||||||
:size="24"
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
|
class="max-w-[80%] rounded-lg p-3 text-sm"
|
||||||
:class="getMessageStyle(message.sender)"
|
:class="getMessageStyle(message.sender)"
|
||||||
>
|
>
|
||||||
<div v-html="formatMessage(message.content)" />
|
<div class="break-words" v-html="formatMessage(message.content)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assistantId: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const isFilterOpen = ref(false);
|
||||||
|
|
||||||
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
|
const assistantOptions = computed(() => [
|
||||||
|
{
|
||||||
|
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
|
||||||
|
value: 'all',
|
||||||
|
action: 'filter',
|
||||||
|
},
|
||||||
|
...assistants.value.map(assistant => ({
|
||||||
|
value: assistant.id,
|
||||||
|
label: assistant.name,
|
||||||
|
action: 'filter',
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedAssistantLabel = computed(() => {
|
||||||
|
const assistant = assistantOptions.value.find(
|
||||||
|
option => option.value === props.assistantId
|
||||||
|
);
|
||||||
|
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
|
||||||
|
selected: assistant ? assistant.label : '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAssistantFilterChange = ({ value }) => {
|
||||||
|
isFilterOpen.value = false;
|
||||||
|
emit('update', value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OnClickOutside @trigger="isFilterOpen = false">
|
||||||
|
<Button
|
||||||
|
:label="selectedAssistantLabel"
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
trailing-icon
|
||||||
|
class="max-w-48"
|
||||||
|
@click="isFilterOpen = !isFilterOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="isFilterOpen"
|
||||||
|
:menu-items="assistantOptions"
|
||||||
|
class="mt-2"
|
||||||
|
@action="handleAssistantFilterChange"
|
||||||
|
/>
|
||||||
|
</OnClickOutside>
|
||||||
|
</template>
|
||||||
+3
-13
@@ -18,7 +18,7 @@ const props = defineProps({
|
|||||||
validator: value => ['create', 'edit'].includes(value),
|
validator: value => ['create', 'edit'].includes(value),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['close', 'created']);
|
const emit = defineEmits(['close']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
@@ -35,18 +35,8 @@ const i18nKey = computed(
|
|||||||
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
|
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const createAssistant = async assistantDetails => {
|
const createAssistant = assistantDetails =>
|
||||||
try {
|
store.dispatch('captainAssistants/create', assistantDetails);
|
||||||
const newAssistant = await store.dispatch(
|
|
||||||
'captainAssistants/create',
|
|
||||||
assistantDetails
|
|
||||||
);
|
|
||||||
emit('created', newAssistant);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
|
||||||
useAlert(errorMessage);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async updatedAssistant => {
|
const handleSubmit = async updatedAssistant => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required, minLength } from '@vuelidate/validators';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
|
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
validator: value => ['edit', 'create'].includes(value),
|
||||||
|
},
|
||||||
|
assistant: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const formState = {
|
||||||
|
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
productName: '',
|
||||||
|
welcomeMessage: '',
|
||||||
|
handoffMessage: '',
|
||||||
|
resolutionMessage: '',
|
||||||
|
instructions: '',
|
||||||
|
features: {
|
||||||
|
conversationFaqs: false,
|
||||||
|
memories: false,
|
||||||
|
citations: false,
|
||||||
|
},
|
||||||
|
temperature: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reactive({ ...initialState });
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
name: { required, minLength: minLength(1) },
|
||||||
|
description: { required, minLength: minLength(1) },
|
||||||
|
productName: { required, minLength: minLength(1) },
|
||||||
|
welcomeMessage: { minLength: minLength(1) },
|
||||||
|
handoffMessage: { minLength: minLength(1) },
|
||||||
|
resolutionMessage: { minLength: minLength(1) },
|
||||||
|
instructions: { minLength: minLength(1) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
|
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||||
|
|
||||||
|
const getErrorMessage = field => {
|
||||||
|
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formErrors = computed(() => ({
|
||||||
|
name: getErrorMessage('name'),
|
||||||
|
description: getErrorMessage('description'),
|
||||||
|
productName: getErrorMessage('productName'),
|
||||||
|
welcomeMessage: getErrorMessage('welcomeMessage'),
|
||||||
|
handoffMessage: getErrorMessage('handoffMessage'),
|
||||||
|
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||||
|
instructions: getErrorMessage('instructions'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateStateFromAssistant = assistant => {
|
||||||
|
const { config = {} } = assistant;
|
||||||
|
state.name = assistant.name;
|
||||||
|
state.description = assistant.description;
|
||||||
|
state.productName = config.product_name;
|
||||||
|
state.welcomeMessage = config.welcome_message;
|
||||||
|
state.handoffMessage = config.handoff_message;
|
||||||
|
state.resolutionMessage = config.resolution_message;
|
||||||
|
state.instructions = config.instructions;
|
||||||
|
state.features = {
|
||||||
|
conversationFaqs: config.feature_faq || false,
|
||||||
|
memories: config.feature_memory || false,
|
||||||
|
citations: config.feature_citation || false,
|
||||||
|
};
|
||||||
|
state.temperature = config.temperature || 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBasicInfoUpdate = async () => {
|
||||||
|
const result = await Promise.all([
|
||||||
|
v$.value.name.$validate(),
|
||||||
|
v$.value.description.$validate(),
|
||||||
|
v$.value.productName.$validate(),
|
||||||
|
]).then(results => results.every(Boolean));
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: state.name,
|
||||||
|
description: state.description,
|
||||||
|
config: {
|
||||||
|
...props.assistant.config,
|
||||||
|
product_name: state.productName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSystemMessagesUpdate = async () => {
|
||||||
|
const result = await Promise.all([
|
||||||
|
v$.value.welcomeMessage.$validate(),
|
||||||
|
v$.value.handoffMessage.$validate(),
|
||||||
|
v$.value.resolutionMessage.$validate(),
|
||||||
|
]).then(results => results.every(Boolean));
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
...props.assistant.config,
|
||||||
|
welcome_message: state.welcomeMessage,
|
||||||
|
handoff_message: state.handoffMessage,
|
||||||
|
resolution_message: state.resolutionMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstructionsUpdate = async () => {
|
||||||
|
const result = await v$.value.instructions.$validate();
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
...props.assistant.config,
|
||||||
|
temperature: state.temperature || 1,
|
||||||
|
instructions: state.instructions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFeaturesUpdate = () => {
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
...props.assistant.config,
|
||||||
|
feature_faq: state.features.conversationFaqs,
|
||||||
|
feature_memory: state.features.memories,
|
||||||
|
feature_citation: state.features.citations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.assistant,
|
||||||
|
newAssistant => {
|
||||||
|
if (props.mode === 'edit' && newAssistant) {
|
||||||
|
updateStateFromAssistant(newAssistant);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
|
<!-- Basic Information Section -->
|
||||||
|
<Accordion
|
||||||
|
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
|
||||||
|
is-open
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 pt-4">
|
||||||
|
<Input
|
||||||
|
v-model="state.name"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
|
||||||
|
:message="formErrors.name"
|
||||||
|
:message-type="formErrors.name ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Editor
|
||||||
|
v-model="state.description"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
|
:message="formErrors.description"
|
||||||
|
:message-type="formErrors.description ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="state.productName"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
|
||||||
|
:message="formErrors.productName"
|
||||||
|
:message-type="formErrors.productName ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click="handleBasicInfoUpdate"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<!-- Instructions Section -->
|
||||||
|
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Editor
|
||||||
|
v-model="state.instructions"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
||||||
|
:message="formErrors.instructions"
|
||||||
|
:max-length="20000"
|
||||||
|
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 mt-4">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
v-model="state.temperature"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-n-slate-11 italic">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
:loading="isLoading"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||||
|
@click="handleInstructionsUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<!-- Greeting Messages Section -->
|
||||||
|
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
|
||||||
|
<div class="flex flex-col gap-4 pt-4">
|
||||||
|
<Editor
|
||||||
|
v-model="state.handoffMessage"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="formErrors.handoffMessage"
|
||||||
|
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Editor
|
||||||
|
v-model="state.resolutionMessage"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="formErrors.resolutionMessage"
|
||||||
|
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
:loading="isLoading"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||||
|
@click="handleSystemMessagesUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
|
||||||
|
<div class="flex flex-col gap-4 pt-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="state.features.conversationFaqs"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="state.features.memories" type="checkbox" />
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="state.features.citations" type="checkbox" />
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
:loading="isLoading"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||||
|
@click="handleFeaturesUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
-1
@@ -118,7 +118,6 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
:message="formErrors.description"
|
:message="formErrors.description"
|
||||||
:message-type="formErrors.description ? 'error' : 'info'"
|
:message-type="formErrors.description ? 'error' : 'info'"
|
||||||
class="z-0"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+2
-38
@@ -3,8 +3,6 @@ import { reactive, computed, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { minLength } from '@vuelidate/validators';
|
import { minLength } from '@vuelidate/validators';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
@@ -19,16 +17,10 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['submit']);
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isCloudFeatureEnabled } = useAccount();
|
|
||||||
|
|
||||||
const isCaptainV2Enabled = computed(() =>
|
|
||||||
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
handoffMessage: '',
|
handoffMessage: '',
|
||||||
resolutionMessage: '',
|
resolutionMessage: '',
|
||||||
instructions: '',
|
|
||||||
temperature: 1,
|
temperature: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,7 +29,6 @@ const state = reactive({ ...initialState });
|
|||||||
const validationRules = {
|
const validationRules = {
|
||||||
handoffMessage: { minLength: minLength(1) },
|
handoffMessage: { minLength: minLength(1) },
|
||||||
resolutionMessage: { minLength: minLength(1) },
|
resolutionMessage: { minLength: minLength(1) },
|
||||||
instructions: { minLength: minLength(1) },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
const v$ = useVuelidate(validationRules, state);
|
||||||
@@ -49,30 +40,20 @@ const getErrorMessage = field => {
|
|||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
handoffMessage: getErrorMessage('handoffMessage'),
|
handoffMessage: getErrorMessage('handoffMessage'),
|
||||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||||
instructions: getErrorMessage('instructions'),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const updateStateFromAssistant = assistant => {
|
const updateStateFromAssistant = assistant => {
|
||||||
const { config = {} } = assistant;
|
const { config = {} } = assistant;
|
||||||
state.handoffMessage = config.handoff_message;
|
state.handoffMessage = config.handoff_message;
|
||||||
state.resolutionMessage = config.resolution_message;
|
state.resolutionMessage = config.resolution_message;
|
||||||
state.instructions = config.instructions;
|
|
||||||
state.temperature = config.temperature || 1;
|
state.temperature = config.temperature || 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSystemMessagesUpdate = async () => {
|
const handleSystemMessagesUpdate = async () => {
|
||||||
const validations = [
|
const result = await Promise.all([
|
||||||
v$.value.handoffMessage.$validate(),
|
v$.value.handoffMessage.$validate(),
|
||||||
v$.value.resolutionMessage.$validate(),
|
v$.value.resolutionMessage.$validate(),
|
||||||
];
|
]).then(results => results.every(Boolean));
|
||||||
|
|
||||||
if (!isCaptainV2Enabled.value) {
|
|
||||||
validations.push(v$.value.instructions.$validate());
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await Promise.all(validations).then(results =>
|
|
||||||
results.every(Boolean)
|
|
||||||
);
|
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -84,10 +65,6 @@ const handleSystemMessagesUpdate = async () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isCaptainV2Enabled.value) {
|
|
||||||
payload.config.instructions = state.instructions;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('submit', payload);
|
emit('submit', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,7 +85,6 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.handoffMessage"
|
:message="formErrors.handoffMessage"
|
||||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||||
class="z-0"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
@@ -117,18 +93,6 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.resolutionMessage"
|
:message="formErrors.resolutionMessage"
|
||||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||||
class="z-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Editor
|
|
||||||
v-if="!isCaptainV2Enabled"
|
|
||||||
v-model="state.instructions"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.LABEL')"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
|
||||||
:message="formErrors.instructions"
|
|
||||||
:max-length="20000"
|
|
||||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
|
||||||
class="z-0"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+1
-12
@@ -8,13 +8,6 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
|||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import DocumentForm from './DocumentForm.vue';
|
import DocumentForm from './DocumentForm.vue';
|
||||||
|
|
||||||
defineProps({
|
|
||||||
assistantId: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -55,11 +48,7 @@ defineExpose({ dialogRef });
|
|||||||
:show-confirm-button="false"
|
:show-confirm-button="false"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<DocumentForm
|
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
|
||||||
:assistant-id="assistantId"
|
|
||||||
@submit="handleSubmit"
|
|
||||||
@cancel="handleCancel"
|
|
||||||
/>
|
|
||||||
<template #footer />
|
<template #footer />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+28
-9
@@ -2,7 +2,7 @@
|
|||||||
import { reactive, computed, ref, nextTick } from 'vue';
|
import { reactive, computed, ref, nextTick } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { minLength, requiredIf, url } from '@vuelidate/validators';
|
import { required, minLength, requiredIf, url } from '@vuelidate/validators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
@@ -10,13 +10,6 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
assistantId: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
@@ -25,11 +18,13 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const formState = {
|
const formState = {
|
||||||
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
|
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
|
||||||
|
assistants: useMapGetter('captainAssistants/getRecords'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
|
assistantId: null,
|
||||||
documentType: 'url',
|
documentType: 'url',
|
||||||
pdfFile: null,
|
pdfFile: null,
|
||||||
};
|
};
|
||||||
@@ -43,11 +38,19 @@ const validationRules = {
|
|||||||
url: requiredIf(() => state.documentType === 'url' && url),
|
url: requiredIf(() => state.documentType === 'url' && url),
|
||||||
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
|
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
|
||||||
},
|
},
|
||||||
|
assistantId: { required },
|
||||||
pdfFile: {
|
pdfFile: {
|
||||||
required: requiredIf(() => state.documentType === 'pdf'),
|
required: requiredIf(() => state.documentType === 'pdf'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assistantList = computed(() =>
|
||||||
|
formState.assistants.value.map(assistant => ({
|
||||||
|
value: assistant.id,
|
||||||
|
label: assistant.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const documentTypeOptions = [
|
const documentTypeOptions = [
|
||||||
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
|
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
|
||||||
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
|
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
|
||||||
@@ -67,6 +70,7 @@ const getErrorMessage = (field, errorKey) => {
|
|||||||
|
|
||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
url: getErrorMessage('url', 'URL'),
|
url: getErrorMessage('url', 'URL'),
|
||||||
|
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||||
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
|
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -102,7 +106,7 @@ const openFileDialog = () => {
|
|||||||
|
|
||||||
const prepareDocumentDetails = () => {
|
const prepareDocumentDetails = () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('document[assistant_id]', props.assistantId);
|
formData.append('document[assistant_id]', state.assistantId);
|
||||||
|
|
||||||
if (state.documentType === 'url') {
|
if (state.documentType === 'url') {
|
||||||
formData.append('document[external_link]', state.url);
|
formData.append('document[external_link]', state.url);
|
||||||
@@ -214,6 +218,21 @@ const handleSubmit = async () => {
|
|||||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<ComboBox
|
||||||
|
id="assistant"
|
||||||
|
v-model="state.assistantId"
|
||||||
|
:options="assistantList"
|
||||||
|
:has-error="!!formErrors.assistantId"
|
||||||
|
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2"
|
||||||
|
:message="formErrors.assistantId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 justify-between items-center w-full">
|
<div class="flex gap-3 justify-between items-center w-full">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+2
-10
@@ -3,7 +3,6 @@ import { ref, computed } from 'vue';
|
|||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import ResponseForm from './ResponseForm.vue';
|
import ResponseForm from './ResponseForm.vue';
|
||||||
@@ -22,7 +21,6 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
const responseForm = ref(null);
|
const responseForm = ref(null);
|
||||||
@@ -41,15 +39,9 @@ const createResponse = responseDetails =>
|
|||||||
const handleSubmit = async updatedResponse => {
|
const handleSubmit = async updatedResponse => {
|
||||||
try {
|
try {
|
||||||
if (props.type === 'edit') {
|
if (props.type === 'edit') {
|
||||||
await updateResponse({
|
await updateResponse(updatedResponse);
|
||||||
...updatedResponse,
|
|
||||||
assistant_id: route.params.assistantId,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await createResponse({
|
await createResponse(updatedResponse);
|
||||||
...updatedResponse,
|
|
||||||
assistant_id: route.params.assistantId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||||
dialogRef.value.close();
|
dialogRef.value.close();
|
||||||
|
|||||||
+32
-2
@@ -8,6 +8,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
|||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
@@ -20,17 +21,18 @@ const props = defineProps({
|
|||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const formState = {
|
const formState = {
|
||||||
uiFlags: useMapGetter('captainResponses/getUIFlags'),
|
uiFlags: useMapGetter('captainResponses/getUIFlags'),
|
||||||
|
assistants: useMapGetter('captainAssistants/getRecords'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
question: '',
|
question: '',
|
||||||
answer: '',
|
answer: '',
|
||||||
|
assistantId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
@@ -38,8 +40,16 @@ const state = reactive({ ...initialState });
|
|||||||
const validationRules = {
|
const validationRules = {
|
||||||
question: { required, minLength: minLength(1) },
|
question: { required, minLength: minLength(1) },
|
||||||
answer: { required, minLength: minLength(1) },
|
answer: { required, minLength: minLength(1) },
|
||||||
|
assistantId: { required },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assistantList = computed(() =>
|
||||||
|
formState.assistants.value.map(assistant => ({
|
||||||
|
value: assistant.id,
|
||||||
|
label: assistant.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||||
@@ -53,6 +63,7 @@ const getErrorMessage = (field, errorKey) => {
|
|||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
question: getErrorMessage('question', 'QUESTION'),
|
question: getErrorMessage('question', 'QUESTION'),
|
||||||
answer: getErrorMessage('answer', 'ANSWER'),
|
answer: getErrorMessage('answer', 'ANSWER'),
|
||||||
|
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleCancel = () => emit('cancel');
|
const handleCancel = () => emit('cancel');
|
||||||
@@ -60,6 +71,7 @@ const handleCancel = () => emit('cancel');
|
|||||||
const prepareDocumentDetails = () => ({
|
const prepareDocumentDetails = () => ({
|
||||||
question: state.question,
|
question: state.question,
|
||||||
answer: state.answer,
|
answer: state.answer,
|
||||||
|
assistant_id: state.assistantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -74,11 +86,12 @@ const handleSubmit = async () => {
|
|||||||
const updateStateFromResponse = response => {
|
const updateStateFromResponse = response => {
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
|
|
||||||
const { question, answer } = response;
|
const { question, answer, assistant } = response;
|
||||||
|
|
||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
question,
|
question,
|
||||||
answer,
|
answer,
|
||||||
|
assistantId: assistant.id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,6 +115,7 @@ watch(
|
|||||||
:message="formErrors.question"
|
:message="formErrors.question"
|
||||||
:message-type="formErrors.question ? 'error' : 'info'"
|
:message-type="formErrors.question ? 'error' : 'info'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
v-model="state.answer"
|
v-model="state.answer"
|
||||||
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
||||||
@@ -110,6 +124,22 @@ watch(
|
|||||||
:max-length="10000"
|
:max-length="10000"
|
||||||
:message-type="formErrors.answer ? 'error' : 'info'"
|
:message-type="formErrors.answer ? 'error' : 'info'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<ComboBox
|
||||||
|
id="assistant"
|
||||||
|
v-model="state.assistantId"
|
||||||
|
:options="assistantList"
|
||||||
|
:has-error="!!formErrors.assistantId"
|
||||||
|
:placeholder="t('CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER')"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||||
|
:message="formErrors.assistantId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'createAssistant']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
|
||||||
|
|
||||||
const currentAssistantId = computed(() => route.params.assistantId);
|
|
||||||
|
|
||||||
const isAssistantActive = assistant => {
|
|
||||||
return assistant.id === Number(currentAssistantId.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchDataForRoute = async (routeName, assistantId) => {
|
|
||||||
const dataFetchMap = {
|
|
||||||
captain_assistants_responses_index: async () => {
|
|
||||||
await store.dispatch('captainResponses/get', { assistantId });
|
|
||||||
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
|
|
||||||
},
|
|
||||||
captain_assistants_responses_pending: async () => {
|
|
||||||
await store.dispatch('captainResponses/get', {
|
|
||||||
assistantId,
|
|
||||||
status: 'pending',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
captain_assistants_documents_index: async () => {
|
|
||||||
await store.dispatch('captainDocuments/get', { assistantId });
|
|
||||||
},
|
|
||||||
captain_assistants_scenarios_index: async () => {
|
|
||||||
await store.dispatch('captainScenarios/get', { assistantId });
|
|
||||||
},
|
|
||||||
captain_assistants_playground_index: () => {
|
|
||||||
// Playground doesn't need pre-fetching, it loads on interaction
|
|
||||||
},
|
|
||||||
captain_assistants_inboxes_index: async () => {
|
|
||||||
await store.dispatch('captainInboxes/get', { assistantId });
|
|
||||||
},
|
|
||||||
captain_tools_index: async () => {
|
|
||||||
await store.dispatch('captainCustomTools/get', { page: 1 });
|
|
||||||
},
|
|
||||||
captain_assistants_settings_index: async () => {
|
|
||||||
await store.dispatch('captainAssistants/show', assistantId);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchFn = dataFetchMap[routeName];
|
|
||||||
if (fetchFn) {
|
|
||||||
await fetchFn();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssistantChange = async assistant => {
|
|
||||||
if (isAssistantActive(assistant)) return;
|
|
||||||
|
|
||||||
const currentRouteName = route.name;
|
|
||||||
const targetRouteName =
|
|
||||||
currentRouteName || 'captain_assistants_responses_index';
|
|
||||||
|
|
||||||
await fetchDataForRoute(targetRouteName, assistant.id);
|
|
||||||
|
|
||||||
await router.push({
|
|
||||||
name: targetRouteName,
|
|
||||||
params: {
|
|
||||||
accountId: route.params.accountId,
|
|
||||||
assistantId: assistant.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateAssistantDialog = () => {
|
|
||||||
emit('createAssistant');
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<h2
|
|
||||||
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
|
|
||||||
>
|
|
||||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ASSISTANTS') }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-n-slate-11">
|
|
||||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
size="sm"
|
|
||||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
|
||||||
@click="openCreateAssistantDialog"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
|
||||||
<Button
|
|
||||||
v-for="assistant in assistants"
|
|
||||||
:key="assistant.id"
|
|
||||||
:label="assistant.name"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
trailing-icon
|
|
||||||
:icon="isAssistantActive(assistant) ? 'i-lucide-check' : ''"
|
|
||||||
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
|
|
||||||
size="sm"
|
|
||||||
@click="handleAssistantChange(assistant)"
|
|
||||||
>
|
|
||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
|
||||||
{{ assistant.name || '' }}
|
|
||||||
</span>
|
|
||||||
<Avatar
|
|
||||||
v-if="assistant"
|
|
||||||
:name="assistant.name"
|
|
||||||
:size="20"
|
|
||||||
icon-name="i-lucide-bot"
|
|
||||||
rounded-full
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex flex-col items-center gap-2 px-4 py-3">
|
|
||||||
<p class="text-sm text-n-slate-11">
|
|
||||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.EMPTY_LIST') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -96,7 +96,6 @@ watch(
|
|||||||
:label="selectedLabel"
|
:label="selectedLabel"
|
||||||
trailing-icon
|
trailing-icon
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
no-animation
|
|
||||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
|
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
|
||||||
:class="{
|
:class="{
|
||||||
focused: open,
|
focused: open,
|
||||||
|
|||||||
@@ -78,10 +78,8 @@ const handleSuggestion = opt => {
|
|||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'captain_assistants_create_index',
|
name: 'captain_assistants_index',
|
||||||
params: {
|
params: { accountId: route.params.accountId },
|
||||||
accountId: route.params.accountId,
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
class="text-n-slate-11 underline hover:text-n-slate-12"
|
class="text-n-slate-11 underline hover:text-n-slate-12"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
@@ -54,17 +53,14 @@ const toggleSidebar = () => {
|
|||||||
v-if="showCopilotLauncher"
|
v-if="showCopilotLauncher"
|
||||||
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
|
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
|
||||||
>
|
>
|
||||||
<ButtonGroup
|
<div class="rounded-full bg-n-alpha-2 p-1">
|
||||||
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
icon="i-woot-captain"
|
icon="i-woot-captain"
|
||||||
no-animation
|
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
||||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
|
||||||
lg
|
lg
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
/>
|
/>
|
||||||
</ButtonGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else />
|
<template v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
menuItems: {
|
menuItems: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
required: true,
|
||||||
validator: value => {
|
validator: value => {
|
||||||
return value.every(item => item.action && item.value && item.label);
|
return value.every(item => item.action && item.value && item.label);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
menuSections: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
thumbnailSize: {
|
thumbnailSize: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 20,
|
default: 20,
|
||||||
@@ -46,62 +42,19 @@ const { t } = useI18n();
|
|||||||
const searchInput = ref(null);
|
const searchInput = ref(null);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
|
||||||
const hasSections = computed(() => props.menuSections.length > 0);
|
|
||||||
|
|
||||||
const flattenedMenuItems = computed(() => {
|
|
||||||
if (!hasSections.value) {
|
|
||||||
return props.menuItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.menuSections.flatMap(section => section.items || []);
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredMenuItems = computed(() => {
|
const filteredMenuItems = computed(() => {
|
||||||
if (!searchQuery.value) return flattenedMenuItems.value;
|
if (!searchQuery.value) return props.menuItems;
|
||||||
|
|
||||||
return flattenedMenuItems.value.filter(item =>
|
return props.menuItems.filter(item =>
|
||||||
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredMenuSections = computed(() => {
|
|
||||||
if (!hasSections.value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!searchQuery.value) {
|
|
||||||
return props.menuSections;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = searchQuery.value.toLowerCase();
|
|
||||||
|
|
||||||
return props.menuSections
|
|
||||||
.map(section => {
|
|
||||||
const filteredItems = (section.items || []).filter(item =>
|
|
||||||
item.label.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: filteredItems,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(section => section.items.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAction = item => {
|
const handleAction = item => {
|
||||||
const { action, value, ...rest } = item;
|
const { action, value, ...rest } = item;
|
||||||
emit('action', { action, value, ...rest });
|
emit('action', { action, value, ...rest });
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldShowEmptyState = computed(() => {
|
|
||||||
if (hasSections.value) {
|
|
||||||
return filteredMenuSections.value.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredMenuItems.value.length === 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (searchInput.value && props.showSearch) {
|
if (searchInput.value && props.showSearch) {
|
||||||
searchInput.value.focus();
|
searchInput.value.focus();
|
||||||
@@ -111,122 +64,54 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
|
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
||||||
:class="{
|
|
||||||
'pt-2': !showSearch,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div
|
<div v-if="showSearch" class="relative">
|
||||||
v-if="showSearch"
|
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
|
<input
|
||||||
>
|
ref="searchInput"
|
||||||
<div class="relative">
|
v-model="searchQuery"
|
||||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
type="search"
|
||||||
<input
|
:placeholder="
|
||||||
ref="searchInput"
|
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||||
v-model="searchQuery"
|
"
|
||||||
type="search"
|
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"
|
||||||
:placeholder="
|
/>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<template v-if="hasSections">
|
<button
|
||||||
<div
|
v-for="(item, index) in filteredMenuItems"
|
||||||
v-for="(section, sectionIndex) in filteredMenuSections"
|
:key="index"
|
||||||
:key="section.title || sectionIndex"
|
type="button"
|
||||||
class="flex flex-col gap-1"
|
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||||
>
|
:class="{
|
||||||
<p
|
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||||
v-if="section.title"
|
'text-n-ruby-11': item.action === 'delete',
|
||||||
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
|
'text-n-slate-12': item.action !== 'delete',
|
||||||
>
|
}"
|
||||||
{{ section.title }}
|
:disabled="item.disabled"
|
||||||
</p>
|
@click="handleAction(item)"
|
||||||
<button
|
>
|
||||||
v-for="(item, itemIndex) in section.items"
|
<slot name="thumbnail" :item="item">
|
||||||
:key="item.value || itemIndex"
|
<Avatar
|
||||||
type="button"
|
v-if="item.thumbnail"
|
||||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
:name="item.thumbnail.name"
|
||||||
:class="{
|
:src="item.thumbnail.src"
|
||||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
:size="thumbnailSize"
|
||||||
'text-n-ruby-11': item.action === 'delete',
|
rounded-full
|
||||||
'text-n-slate-12': item.action !== 'delete',
|
|
||||||
}"
|
|
||||||
:disabled="item.disabled"
|
|
||||||
@click="handleAction(item)"
|
|
||||||
>
|
|
||||||
<slot name="thumbnail" :item="item">
|
|
||||||
<Avatar
|
|
||||||
v-if="item.thumbnail"
|
|
||||||
:name="item.thumbnail.name"
|
|
||||||
:src="item.thumbnail.src"
|
|
||||||
:size="thumbnailSize"
|
|
||||||
rounded-full
|
|
||||||
/>
|
|
||||||
</slot>
|
|
||||||
<Icon
|
|
||||||
v-if="item.icon"
|
|
||||||
:icon="item.icon"
|
|
||||||
class="flex-shrink-0 size-3.5"
|
|
||||||
/>
|
|
||||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
|
||||||
<span
|
|
||||||
v-if="item.label"
|
|
||||||
class="min-w-0 text-sm truncate"
|
|
||||||
:class="labelClass"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
|
||||||
class="h-px bg-n-alpha-2 mx-2 my-1"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</slot>
|
||||||
</template>
|
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
||||||
<template v-else>
|
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||||
<button
|
<span
|
||||||
v-for="(item, index) in filteredMenuItems"
|
v-if="item.label"
|
||||||
:key="index"
|
class="min-w-0 text-sm truncate"
|
||||||
type="button"
|
:class="labelClass"
|
||||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
:class="{
|
|
||||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
|
||||||
'text-n-ruby-11': item.action === 'delete',
|
|
||||||
'text-n-slate-12': item.action !== 'delete',
|
|
||||||
}"
|
|
||||||
:disabled="item.disabled"
|
|
||||||
@click="handleAction(item)"
|
|
||||||
>
|
>
|
||||||
<slot name="thumbnail" :item="item">
|
{{ item.label }}
|
||||||
<Avatar
|
</span>
|
||||||
v-if="item.thumbnail"
|
</button>
|
||||||
:name="item.thumbnail.name"
|
|
||||||
:src="item.thumbnail.src"
|
|
||||||
:size="thumbnailSize"
|
|
||||||
rounded-full
|
|
||||||
/>
|
|
||||||
</slot>
|
|
||||||
<Icon
|
|
||||||
v-if="item.icon"
|
|
||||||
:icon="item.icon"
|
|
||||||
class="flex-shrink-0 size-3.5"
|
|
||||||
/>
|
|
||||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
|
||||||
<span
|
|
||||||
v-if="item.label"
|
|
||||||
class="min-w-0 text-sm truncate"
|
|
||||||
:class="labelClass"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowEmptyState"
|
v-if="filteredMenuItems.length === 0"
|
||||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
|
||||||
import BaseBubble from './Base.vue';
|
import BaseBubble from './Base.vue';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
@@ -12,6 +11,7 @@ import { downloadFile } from '@chatwoot/utils';
|
|||||||
|
|
||||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['error']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||||
@@ -20,16 +20,14 @@ const attachment = computed(() => {
|
|||||||
return attachments.value[0];
|
return attachments.value[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
|
const hasError = ref(false);
|
||||||
|
|
||||||
const showGallery = ref(false);
|
const showGallery = ref(false);
|
||||||
const isDownloading = ref(false);
|
const isDownloading = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
const handleError = () => {
|
||||||
if (attachment.value?.dataUrl) {
|
hasError.value = true;
|
||||||
loadWithRetry(attachment.value.dataUrl);
|
emit('error');
|
||||||
}
|
};
|
||||||
});
|
|
||||||
|
|
||||||
const downloadAttachment = async () => {
|
const downloadAttachment = async () => {
|
||||||
const { fileType, dataUrl, extension } = attachment.value;
|
const { fileType, dataUrl, extension } = attachment.value;
|
||||||
@@ -42,10 +40,6 @@ const downloadAttachment = async () => {
|
|||||||
isDownloading.value = false;
|
isDownloading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageError = () => {
|
|
||||||
hasError.value = true;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -60,12 +54,14 @@ const handleImageError = () => {
|
|||||||
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
|
<div v-else class="relative group rounded-lg overflow-hidden">
|
||||||
<img
|
<img
|
||||||
class="skip-context-menu"
|
class="skip-context-menu"
|
||||||
:src="attachment.dataUrl"
|
:src="attachment.dataUrl"
|
||||||
:width="attachment.width"
|
:width="attachment.width"
|
||||||
:height="attachment.height"
|
:height="attachment.height"
|
||||||
|
@click="onClick"
|
||||||
|
@error="handleError"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
|
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
|
||||||
@@ -90,7 +86,7 @@ const handleImageError = () => {
|
|||||||
v-model:show="showGallery"
|
v-model:show="showGallery"
|
||||||
:attachment="useSnakeCase(attachment)"
|
:attachment="useSnakeCase(attachment)"
|
||||||
:all-attachments="filteredCurrentChatAttachments"
|
:all-attachments="filteredCurrentChatAttachments"
|
||||||
@error="handleImageError"
|
@error="handleError"
|
||||||
@close="() => (showGallery = false)"
|
@close="() => (showGallery = false)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,102 +1,41 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useMessageContext } from '../provider.js';
|
|
||||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
|
import { useMessageContext } from '../provider.js';
|
||||||
|
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||||
|
|
||||||
const LABEL_MAP = {
|
const { contentAttributes } = useMessageContext();
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
|
||||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBTEXT_MAP = {
|
|
||||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
|
||||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICON_MAP = {
|
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
|
||||||
};
|
|
||||||
|
|
||||||
const BG_COLOR_MAP = {
|
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
|
||||||
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
|
||||||
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
|
||||||
};
|
|
||||||
|
|
||||||
const { contentAttributes, messageType } = useMessageContext();
|
|
||||||
|
|
||||||
const data = computed(() => contentAttributes.value?.data);
|
const data = computed(() => contentAttributes.value?.data);
|
||||||
const status = computed(() => data.value?.status?.toString());
|
|
||||||
|
|
||||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
const status = computed(() => data.value?.status);
|
||||||
const isFailed = computed(() =>
|
const direction = computed(() => data.value?.call_direction);
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
||||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
useVoiceCallStatus(status, direction);
|
||||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
|
||||||
return isOutbound.value
|
const containerRingClass = computed(() => {
|
||||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
}
|
|
||||||
return isFailed.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const subtextKey = computed(() => {
|
|
||||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
|
||||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
|
||||||
}
|
|
||||||
return isFailed.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconName = computed(() => {
|
|
||||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
|
||||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
|
||||||
});
|
|
||||||
|
|
||||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="p-0 border-none" hide-meta>
|
<BaseBubble class="p-0 border-none" hide-meta>
|
||||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
<div
|
||||||
|
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
:class="containerRingClass"
|
||||||
|
>
|
||||||
<div class="flex gap-3 items-center p-3 w-full">
|
<div class="flex gap-3 items-center p-3 w-full">
|
||||||
<div
|
<div
|
||||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||||
:class="bgColor"
|
:class="bubbleIconBg"
|
||||||
>
|
>
|
||||||
<Icon
|
<span class="text-xl" :class="bubbleIconName" />
|
||||||
class="size-5"
|
|
||||||
:icon="iconName"
|
|
||||||
:class="{
|
|
||||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
|
||||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex overflow-hidden flex-col flex-grow">
|
<div class="flex overflow-hidden flex-col flex-grow">
|
||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
|
||||||
{{ $t(labelKey) }}
|
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
|
||||||
</span>
|
|
||||||
<span class="text-xs text-n-slate-11">
|
|
||||||
{{ $t(subtextKey) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,16 +73,3 @@ export const MEDIA_TYPES = [
|
|||||||
ATTACHMENT_TYPES.AUDIO,
|
ATTACHMENT_TYPES.AUDIO,
|
||||||
ATTACHMENT_TYPES.IG_REEL,
|
ATTACHMENT_TYPES.IG_REEL,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const VOICE_CALL_STATUS = {
|
|
||||||
IN_PROGRESS: 'in-progress',
|
|
||||||
RINGING: 'ringing',
|
|
||||||
COMPLETED: 'completed',
|
|
||||||
NO_ANSWER: 'no-answer',
|
|
||||||
FAILED: 'failed',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VOICE_CALL_DIRECTION = {
|
|
||||||
INBOUND: 'inbound',
|
|
||||||
OUTBOUND: 'outbound',
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
const emit = defineEmits(['update:currentPage']);
|
const emit = defineEmits(['update:currentPage']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
|
|
||||||
|
|
||||||
const totalPages = computed(() =>
|
const totalPages = computed(() =>
|
||||||
Math.ceil(props.totalItems / props.itemsPerPage)
|
Math.ceil(props.totalItems / props.itemsPerPage)
|
||||||
@@ -45,27 +43,21 @@ const changePage = newPage => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentPageInformation = computed(() => {
|
const currentPageInformation = computed(() => {
|
||||||
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
|
|
||||||
return t(
|
return t(
|
||||||
translationKey,
|
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
|
||||||
{
|
{
|
||||||
startItem: formatFullNumber(startItem.value),
|
startItem: startItem.value,
|
||||||
endItem: formatFullNumber(endItem.value),
|
endItem: endItem.value,
|
||||||
totalItems: formatCompactNumber(props.totalItems),
|
totalItems: props.totalItems,
|
||||||
},
|
}
|
||||||
Number(props.totalItems)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageInfo = computed(() => {
|
const pageInfo = computed(() => {
|
||||||
return t(
|
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
|
||||||
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
|
currentPage: '',
|
||||||
{
|
totalPages: totalPages.value,
|
||||||
currentPage: '',
|
});
|
||||||
totalPages: formatCompactNumber(totalPages.value),
|
|
||||||
},
|
|
||||||
Number(totalPages.value)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -99,11 +91,9 @@ const pageInfo = computed(() => {
|
|||||||
/>
|
/>
|
||||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
||||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||||
{{ formatFullNumber(currentPage) }}
|
{{ currentPage }}
|
||||||
</span>
|
|
||||||
<span class="truncate">
|
|
||||||
{{ pageInfo }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span class="truncate">{{ pageInfo }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-chevron-right"
|
icon="i-lucide-chevron-right"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
isMobileSidebarOpen: {
|
isMobileSidebarOpen: {
|
||||||
@@ -46,17 +45,14 @@ const toggleSidebar = () => {
|
|||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<ButtonGroup
|
<div class="rounded-full bg-n-alpha-2 p-1">
|
||||||
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-menu"
|
icon="i-lucide-menu"
|
||||||
no-animation
|
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
||||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
|
||||||
lg
|
lg
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
/>
|
/>
|
||||||
</ButtonGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else />
|
<template v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -221,70 +221,26 @@ const menuItems = computed(() => {
|
|||||||
name: 'Captain',
|
name: 'Captain',
|
||||||
icon: 'i-woot-captain',
|
icon: 'i-woot-captain',
|
||||||
label: t('SIDEBAR.CAPTAIN'),
|
label: t('SIDEBAR.CAPTAIN'),
|
||||||
activeOn: ['captain_assistants_create_index'],
|
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
name: 'FAQs',
|
name: 'Assistants',
|
||||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
|
||||||
activeOn: [
|
to: accountScopedRoute('captain_assistants_index'),
|
||||||
'captain_assistants_responses_index',
|
|
||||||
'captain_assistants_responses_pending',
|
|
||||||
],
|
|
||||||
to: accountScopedRoute('captain_assistants_index', {
|
|
||||||
navigationPath: 'captain_assistants_responses_index',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Documents',
|
name: 'Documents',
|
||||||
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
|
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
|
||||||
activeOn: ['captain_assistants_documents_index'],
|
to: accountScopedRoute('captain_documents_index'),
|
||||||
to: accountScopedRoute('captain_assistants_index', {
|
|
||||||
navigationPath: 'captain_assistants_documents_index',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Scenarios',
|
name: 'Responses',
|
||||||
label: t('SIDEBAR.CAPTAIN_SCENARIOS'),
|
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||||
activeOn: ['captain_assistants_scenarios_index'],
|
to: accountScopedRoute('captain_responses_index'),
|
||||||
to: accountScopedRoute('captain_assistants_index', {
|
|
||||||
navigationPath: 'captain_assistants_scenarios_index',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Playground',
|
|
||||||
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
|
|
||||||
activeOn: ['captain_assistants_playground_index'],
|
|
||||||
to: accountScopedRoute('captain_assistants_index', {
|
|
||||||
navigationPath: 'captain_assistants_playground_index',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inboxes',
|
|
||||||
label: t('SIDEBAR.CAPTAIN_INBOXES'),
|
|
||||||
activeOn: ['captain_assistants_inboxes_index'],
|
|
||||||
to: accountScopedRoute('captain_assistants_index', {
|
|
||||||
navigationPath: 'captain_assistants_inboxes_index',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Tools',
|
name: 'Tools',
|
||||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||||
activeOn: ['captain_tools_index'],
|
to: accountScopedRoute('captain_tools_index'),
|
||||||
to: accountScopedRoute('captain_assistants_index', {
|
|
||||||
navigationPath: 'captain_tools_index',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Settings',
|
|
||||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
|
||||||
activeOn: [
|
|
||||||
'captain_assistants_settings_index',
|
|
||||||
'captain_assistants_guidelines_index',
|
|
||||||
'captain_assistants_guardrails_index',
|
|
||||||
],
|
|
||||||
to: accountScopedRoute('captain_assistants_index', {
|
|
||||||
navigationPath: 'captain_assistants_settings_index',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -351,23 +307,6 @@ const menuItems = computed(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Companies',
|
|
||||||
label: t('SIDEBAR.COMPANIES'),
|
|
||||||
icon: 'i-lucide-building-2',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'All Companies',
|
|
||||||
label: t('SIDEBAR.ALL_COMPANIES'),
|
|
||||||
to: accountScopedRoute(
|
|
||||||
'companies_dashboard_index',
|
|
||||||
{},
|
|
||||||
{ page: 1, search: undefined }
|
|
||||||
),
|
|
||||||
activeOn: ['companies_dashboard_index'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Reports',
|
name: 'Reports',
|
||||||
label: t('SIDEBAR.REPORTS'),
|
label: t('SIDEBAR.REPORTS'),
|
||||||
|
|||||||
@@ -19,46 +19,19 @@ export function useSidebarContext() {
|
|||||||
return '/';
|
return '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to find route definition by name without resolving
|
|
||||||
const findRouteByName = name => {
|
|
||||||
const routes = router.getRoutes();
|
|
||||||
return routes.find(route => route.name === name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvePermissions = to => {
|
const resolvePermissions = to => {
|
||||||
if (!to) return [];
|
if (to) return router.resolve(to)?.meta?.permissions ?? [];
|
||||||
|
return [];
|
||||||
// If navigationPath param exists, get the target route definition
|
|
||||||
if (to.params?.navigationPath) {
|
|
||||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
|
||||||
return targetRoute?.meta?.permissions ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return router.resolve(to)?.meta?.permissions ?? [];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveFeatureFlag = to => {
|
const resolveFeatureFlag = to => {
|
||||||
if (!to) return '';
|
if (to) return router.resolve(to)?.meta?.featureFlag || '';
|
||||||
|
return '';
|
||||||
// If navigationPath param exists, get the target route definition
|
|
||||||
if (to.params?.navigationPath) {
|
|
||||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
|
||||||
return targetRoute?.meta?.featureFlag || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return router.resolve(to)?.meta?.featureFlag || '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveInstallationType = to => {
|
const resolveInstallationType = to => {
|
||||||
if (!to) return [];
|
if (to) return router.resolve(to)?.meta?.installationTypes || [];
|
||||||
|
return [];
|
||||||
// If navigationPath param exists, get the target route definition
|
|
||||||
if (to.params?.navigationPath) {
|
|
||||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
|
||||||
return targetRoute?.meta?.installationTypes || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return router.resolve(to)?.meta?.installationTypes || [];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllowed = to => {
|
const isAllowed = to => {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useResizeObserver } from '@vueuse/core';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialActiveTab: {
|
initialActiveTab: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -24,32 +22,6 @@ const emit = defineEmits(['tabChanged']);
|
|||||||
|
|
||||||
const activeTab = computed(() => props.initialActiveTab);
|
const activeTab = computed(() => props.initialActiveTab);
|
||||||
|
|
||||||
const tabRefs = ref([]);
|
|
||||||
const indicatorStyle = ref({});
|
|
||||||
const enableTransition = ref(false);
|
|
||||||
|
|
||||||
const activeElement = computed(() => tabRefs.value[activeTab.value]);
|
|
||||||
|
|
||||||
const updateIndicator = () => {
|
|
||||||
if (!activeElement.value) return;
|
|
||||||
|
|
||||||
indicatorStyle.value = {
|
|
||||||
left: `${activeElement.value.offsetLeft}px`,
|
|
||||||
width: `${activeElement.value.offsetWidth}px`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
useResizeObserver(activeElement, () => {
|
|
||||||
if (enableTransition.value || !activeElement.value) updateIndicator();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
updateIndicator();
|
|
||||||
nextTick(() => {
|
|
||||||
enableTransition.value = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectTab = index => {
|
const selectTab = index => {
|
||||||
emit('tabChanged', props.tabs[index]);
|
emit('tabChanged', props.tabs[index]);
|
||||||
};
|
};
|
||||||
@@ -65,30 +37,20 @@ const showDivider = index => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit">
|
||||||
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"
|
|
||||||
:class="{ 'transition-all duration-300 ease-out': enableTransition }"
|
|
||||||
:style="indicatorStyle"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-for="(tab, index) in tabs" :key="index">
|
<template v-for="(tab, index) in tabs" :key="index">
|
||||||
<button
|
<button
|
||||||
:ref="el => (tabRefs[index] = el)"
|
class="relative px-4 truncate py-1.5 text-sm border-0 outline-1 outline rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand"
|
||||||
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
|
|
||||||
:class="[
|
:class="[
|
||||||
activeTab === index
|
activeTab === index
|
||||||
? 'text-n-blue-text scale-100'
|
? 'text-n-blue-text bg-n-solid-active outline-n-container dark:outline-transparent'
|
||||||
: 'text-n-slate-10 scale-[0.98]',
|
: 'text-n-slate-10 outline-transparent h-8',
|
||||||
]"
|
]"
|
||||||
@click="selectTab(index)"
|
@click="selectTab(index)"
|
||||||
>
|
>
|
||||||
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
|
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="index < tabs.length - 1"
|
|
||||||
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
|
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
|
||||||
:class="
|
:class="
|
||||||
showDivider(index)
|
showDivider(index)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
CMD_RESOLVE_CONVERSATION,
|
CMD_RESOLVE_CONVERSATION,
|
||||||
} from 'dashboard/helper/commandbar/events';
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
|
||||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -134,7 +133,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative flex items-center justify-end resolve-actions">
|
<div class="relative flex items-center justify-end resolve-actions">
|
||||||
<ButtonGroup
|
<div
|
||||||
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
||||||
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
||||||
>
|
>
|
||||||
@@ -143,7 +142,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
|
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="slate"
|
color="slate"
|
||||||
no-animation
|
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@click="onCmdResolveConversation"
|
@click="onCmdResolveConversation"
|
||||||
@@ -153,7 +151,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
|
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="slate"
|
color="slate"
|
||||||
no-animation
|
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@click="onCmdOpenConversation"
|
@click="onCmdOpenConversation"
|
||||||
@@ -163,7 +160,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
:label="t('CONVERSATION.HEADER.OPEN_ACTION')"
|
:label="t('CONVERSATION.HEADER.OPEN_ACTION')"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="slate"
|
color="slate"
|
||||||
no-animation
|
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@click="onCmdOpenConversation"
|
@click="onCmdOpenConversation"
|
||||||
/>
|
/>
|
||||||
@@ -173,13 +169,12 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
size="sm"
|
size="sm"
|
||||||
no-animation
|
|
||||||
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
|
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
|
||||||
color="slate"
|
color="slate"
|
||||||
trailing-icon
|
trailing-icon
|
||||||
@click="openDropdown"
|
@click="openDropdown"
|
||||||
/>
|
/>
|
||||||
</ButtonGroup>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showActionsDropdown"
|
v-if="showActionsDropdown"
|
||||||
v-on-clickaway="closeDropdown"
|
v-on-clickaway="closeDropdown"
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ defineProps({
|
|||||||
slate
|
slate
|
||||||
sm
|
sm
|
||||||
class="relative"
|
class="relative"
|
||||||
no-animation
|
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:trailing-icon="trailingIcon"
|
:trailing-icon="trailingIcon"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -302,16 +302,7 @@ function isBodyEmpty(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEmptyBodyWithSignature() {
|
function handleEmptyBodyWithSignature() {
|
||||||
const { schema, tr, doc } = state;
|
const { schema, tr } = state;
|
||||||
|
|
||||||
const isEmptyParagraph = node =>
|
|
||||||
node && node.type === schema.nodes.paragraph && node.content.size === 0;
|
|
||||||
|
|
||||||
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
|
|
||||||
if (isEmptyParagraph(doc.firstChild)) {
|
|
||||||
focusEditorInputField('start');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// create a paragraph node and
|
// create a paragraph node and
|
||||||
// start a transaction to append it at the end
|
// start a transaction to append it at the end
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const translateValue = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
|
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:class="{
|
:class="{
|
||||||
'cursor-not-allowed': disabled,
|
'cursor-not-allowed': disabled,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||||
|
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import MessagePreview from './MessagePreview.vue';
|
import MessagePreview from './MessagePreview.vue';
|
||||||
@@ -13,7 +14,6 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
|
|||||||
import PriorityMark from './PriorityMark.vue';
|
import PriorityMark from './PriorityMark.vue';
|
||||||
import SLACardLabel from './components/SLACardLabel.vue';
|
import SLACardLabel from './components/SLACardLabel.vue';
|
||||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
activeLabel: { type: String, default: '' },
|
activeLabel: { type: String, default: '' },
|
||||||
@@ -83,10 +83,15 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
|||||||
|
|
||||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||||
|
|
||||||
const voiceCallData = computed(() => ({
|
const callStatus = computed(
|
||||||
status: props.chat.additional_attributes?.call_status,
|
() => props.chat.additional_attributes?.call_status
|
||||||
direction: props.chat.additional_attributes?.call_direction,
|
);
|
||||||
}));
|
const callDirection = computed(
|
||||||
|
() => props.chat.additional_attributes?.call_direction
|
||||||
|
);
|
||||||
|
|
||||||
|
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
||||||
|
useVoiceCallStatus(callStatus, callDirection);
|
||||||
|
|
||||||
const inboxId = computed(() => props.chat.inbox_id);
|
const inboxId = computed(() => props.chat.inbox_id);
|
||||||
|
|
||||||
@@ -312,13 +317,20 @@ const deleteConversation = () => {
|
|||||||
>
|
>
|
||||||
{{ currentContact.name }}
|
{{ currentContact.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<VoiceCallStatus
|
<div
|
||||||
v-if="voiceCallData.status"
|
v-if="callStatus"
|
||||||
key="voice-status-row"
|
key="voice-status-row"
|
||||||
:status="voiceCallData.status"
|
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
:direction="voiceCallData.direction"
|
:class="messagePreviewClass"
|
||||||
:message-preview-class="messagePreviewClass"
|
>
|
||||||
/>
|
<span
|
||||||
|
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
||||||
|
:class="[voiceIconColor]"
|
||||||
|
/>
|
||||||
|
<span class="mx-1">
|
||||||
|
{{ $t(voiceLabelKey) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<MessagePreview
|
<MessagePreview
|
||||||
v-else-if="lastMessageInChat"
|
v-else-if="lastMessageInChat"
|
||||||
key="message-preview"
|
key="message-preview"
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ export default {
|
|||||||
|
|
||||||
created() {
|
created() {
|
||||||
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||||
|
// when a new message comes in, we refetch the label suggestions
|
||||||
|
emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
|
||||||
// when a message is sent we set the flag to true this hides the label suggestions,
|
// when a message is sent we set the flag to true this hides the label suggestions,
|
||||||
// until the chat is changed and the flag is reset in the watch for currentChat
|
// until the chat is changed and the flag is reset in the watch for currentChat
|
||||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
||||||
@@ -289,10 +291,6 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Early exit if conversation already has labels - no need to suggest more
|
|
||||||
const existingLabels = this.currentChat?.labels || [];
|
|
||||||
if (existingLabels.length > 0) return;
|
|
||||||
|
|
||||||
// method available in mixin, need to ensure that integrations are present
|
// method available in mixin, need to ensure that integrations are present
|
||||||
await this.fetchIntegrationsIfRequired();
|
await this.fetchIntegrationsIfRequired();
|
||||||
|
|
||||||
|
|||||||
@@ -174,31 +174,19 @@ export default {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
showWhatsappTemplates() {
|
showWhatsappTemplates() {
|
||||||
// We support templates for API channels if someone updates templates manually via API
|
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||||
// That's why we don't explicitly check for channel type here
|
|
||||||
const templates = this.$store.getters['inboxes/getWhatsAppTemplates'](
|
|
||||||
this.inboxId
|
|
||||||
);
|
|
||||||
return !!(templates && templates.length) && !this.isPrivate;
|
|
||||||
},
|
},
|
||||||
showContentTemplates() {
|
showContentTemplates() {
|
||||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||||
},
|
},
|
||||||
isPrivate() {
|
isPrivate() {
|
||||||
if (
|
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||||
this.currentChat.can_reply ||
|
|
||||||
this.isAWhatsAppChannel ||
|
|
||||||
this.isAPIInbox
|
|
||||||
) {
|
|
||||||
return this.isOnPrivateNote;
|
return this.isOnPrivateNote;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
isReplyRestricted() {
|
isReplyRestricted() {
|
||||||
return (
|
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
|
||||||
!this.currentChat?.can_reply &&
|
|
||||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
inboxId() {
|
inboxId() {
|
||||||
return this.currentChat.inbox_id;
|
return this.currentChat.inbox_id;
|
||||||
@@ -454,7 +442,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
if (canReply || this.isAWhatsAppChannel) {
|
||||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||||
} else {
|
} else {
|
||||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||||
@@ -873,8 +861,7 @@ export default {
|
|||||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
|
||||||
this.replyType = mode;
|
|
||||||
if (this.showRichContentEditor) {
|
if (this.showRichContentEditor) {
|
||||||
if (this.isRecordingAudio) {
|
if (this.isRecordingAudio) {
|
||||||
this.toggleAudioRecorder();
|
this.toggleAudioRecorder();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const assignedAgent = computed({
|
|||||||
return currentChat.value?.meta?.assignee;
|
return currentChat.value?.meta?.assignee;
|
||||||
},
|
},
|
||||||
set(agent) {
|
set(agent) {
|
||||||
const agentId = agent ? agent.id : null;
|
const agentId = agent ? agent.id : 0;
|
||||||
store.dispatch('setCurrentChatAssignee', agent);
|
store.dispatch('setCurrentChatAssignee', agent);
|
||||||
store.dispatch('assignAgent', {
|
store.dispatch('assignAgent', {
|
||||||
conversationId: currentChat.value?.id,
|
conversationId: currentChat.value?.id,
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import {
|
|
||||||
VOICE_CALL_STATUS,
|
|
||||||
VOICE_CALL_DIRECTION,
|
|
||||||
} from 'dashboard/components-next/message/constants';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
status: { type: String, default: '' },
|
|
||||||
direction: { type: String, default: '' },
|
|
||||||
messagePreviewClass: { type: [String, Array, Object], default: '' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const LABEL_KEYS = {
|
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
|
||||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICON_MAP = {
|
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLOR_MAP = {
|
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
|
||||||
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
|
||||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOutbound = computed(
|
|
||||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
|
||||||
);
|
|
||||||
const isFailed = computed(() =>
|
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
|
||||||
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
|
||||||
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
}
|
|
||||||
return isFailed.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconName = computed(() => {
|
|
||||||
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
|
||||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusColor = computed(
|
|
||||||
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
|
||||||
:class="messagePreviewClass"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
class="inline-block -mt-0.5 align-middle size-4"
|
|
||||||
:icon="iconName"
|
|
||||||
:class="statusColor"
|
|
||||||
/>
|
|
||||||
<span class="mx-1" :class="statusColor">
|
|
||||||
{{ $t(labelKey) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
export const useLoadWithRetry = (config = {}) => {
|
|
||||||
const maxRetry = config.max_retry || 3;
|
|
||||||
const backoff = config.backoff || 1000;
|
|
||||||
|
|
||||||
const isLoaded = ref(false);
|
|
||||||
const hasError = ref(false);
|
|
||||||
|
|
||||||
const loadWithRetry = async url => {
|
|
||||||
const attemptLoad = () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
isLoaded.value = true;
|
|
||||||
hasError.value = false;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
reject(new Error('Failed to load image'));
|
|
||||||
};
|
|
||||||
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sleep = ms => {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const retry = async (attempt = 0) => {
|
|
||||||
try {
|
|
||||||
await attemptLoad();
|
|
||||||
} catch (error) {
|
|
||||||
if (attempt + 1 >= maxRetry) {
|
|
||||||
hasError.value = true;
|
|
||||||
isLoaded.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await sleep(backoff * (attempt + 1));
|
|
||||||
await retry(attempt + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await retry();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLoaded,
|
|
||||||
hasError,
|
|
||||||
loadWithRetry,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -12,11 +12,7 @@ vi.mock('dashboard/composables', () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('vue-i18n');
|
vi.mock('vue-i18n');
|
||||||
vi.mock('activestorage');
|
vi.mock('activestorage');
|
||||||
vi.mock('shared/helpers/FileHelper', () => ({
|
vi.mock('shared/helpers/FileHelper');
|
||||||
checkFileSizeLimit: vi.fn(),
|
|
||||||
resolveMaximumFileUploadSize: vi.fn(value => Number(value) || 40),
|
|
||||||
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE: 40,
|
|
||||||
}));
|
|
||||||
vi.mock('@chatwoot/utils');
|
vi.mock('@chatwoot/utils');
|
||||||
|
|
||||||
describe('useFileUpload', () => {
|
describe('useFileUpload', () => {
|
||||||
@@ -40,9 +36,7 @@ describe('useFileUpload', () => {
|
|||||||
getCurrentAccountId: { value: '123' },
|
getCurrentAccountId: { value: '123' },
|
||||||
getCurrentUser: { value: { access_token: 'test-token' } },
|
getCurrentUser: { value: { access_token: 'test-token' } },
|
||||||
getSelectedChat: { value: { id: '456' } },
|
getSelectedChat: { value: { id: '456' } },
|
||||||
'globalConfig/get': {
|
'globalConfig/get': { value: { directUploadsEnabled: true } },
|
||||||
value: { directUploadsEnabled: true, maximumFileUploadSize: 40 },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
return getterMap[getter];
|
return getterMap[getter];
|
||||||
});
|
});
|
||||||
@@ -92,9 +86,7 @@ describe('useFileUpload', () => {
|
|||||||
getCurrentAccountId: { value: '123' },
|
getCurrentAccountId: { value: '123' },
|
||||||
getCurrentUser: { value: { access_token: 'test-token' } },
|
getCurrentUser: { value: { access_token: 'test-token' } },
|
||||||
getSelectedChat: { value: { id: '456' } },
|
getSelectedChat: { value: { id: '456' } },
|
||||||
'globalConfig/get': {
|
'globalConfig/get': { value: { directUploadsEnabled: false } },
|
||||||
value: { directUploadsEnabled: false, maximumFileUploadSize: 40 },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
return getterMap[getter];
|
return getterMap[getter];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from 'dashboard/constants/automation';
|
} from 'dashboard/constants/automation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is a shared composables that holds utilities used to build dropdown and file options
|
* This is a shared composables that holds utilites used to build dropdown and file options
|
||||||
* @returns {Object} An object containing various automation-related functions and computed properties.
|
* @returns {Object} An object containing various automation-related functions and computed properties.
|
||||||
*/
|
*/
|
||||||
export default function useAutomationValues() {
|
export default function useAutomationValues() {
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { DirectUpload } from 'activestorage';
|
import { DirectUpload } from 'activestorage';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||||
import {
|
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||||
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE,
|
|
||||||
resolveMaximumFileUploadSize,
|
|
||||||
} from 'shared/helpers/FileHelper';
|
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for handling file uploads in conversations
|
* Composable for handling file uploads in conversations
|
||||||
@@ -25,34 +21,18 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
|
|||||||
const currentChat = useMapGetter('getSelectedChat');
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
const globalConfig = useMapGetter('globalConfig/get');
|
const globalConfig = useMapGetter('globalConfig/get');
|
||||||
|
|
||||||
const installationLimit = resolveMaximumFileUploadSize(
|
|
||||||
globalConfig.value?.maximumFileUploadSize
|
|
||||||
);
|
|
||||||
|
|
||||||
// helper: compute max upload size for a given file's mime
|
// helper: compute max upload size for a given file's mime
|
||||||
const maxSizeFor = mime => {
|
const maxSizeFor = mime => {
|
||||||
// Use default/installation limit for private notes
|
// Use default file size limit for private notes
|
||||||
if (isPrivateNote) {
|
if (isPrivateNote) {
|
||||||
return installationLimit;
|
return MAXIMUM_FILE_UPLOAD_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelType = inbox?.channel_type;
|
return getMaxUploadSizeByChannel({
|
||||||
|
channelType: inbox?.channel_type,
|
||||||
if (!channelType || channelType === INBOX_TYPES.WEB) {
|
|
||||||
return installationLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelLimit = getMaxUploadSizeByChannel({
|
|
||||||
channelType,
|
|
||||||
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
|
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
|
||||||
mime, // e.g. 'image/png'
|
mime, // e.g. 'image/png'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (channelLimit === DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE) {
|
|
||||||
return installationLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.min(channelLimit, installationLimit);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const alertOverLimit = maxSizeMB =>
|
const alertOverLimit = maxSizeMB =>
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { computed, unref } from 'vue';
|
||||||
|
|
||||||
|
const CALL_STATUSES = {
|
||||||
|
IN_PROGRESS: 'in-progress',
|
||||||
|
RINGING: 'ringing',
|
||||||
|
NO_ANSWER: 'no-answer',
|
||||||
|
BUSY: 'busy',
|
||||||
|
FAILED: 'failed',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
CANCELED: 'canceled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CALL_DIRECTIONS = {
|
||||||
|
INBOUND: 'inbound',
|
||||||
|
OUTBOUND: 'outbound',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for handling voice call status display logic
|
||||||
|
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
|
||||||
|
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
|
||||||
|
* @returns {Object} UI properties for displaying call status
|
||||||
|
*/
|
||||||
|
export function useVoiceCallStatus(statusRef, directionRef) {
|
||||||
|
const status = computed(() => unref(statusRef)?.toString());
|
||||||
|
const direction = computed(() => unref(directionRef)?.toString());
|
||||||
|
|
||||||
|
// Status group helpers
|
||||||
|
const isFailedStatus = computed(() =>
|
||||||
|
[
|
||||||
|
CALL_STATUSES.NO_ANSWER,
|
||||||
|
CALL_STATUSES.BUSY,
|
||||||
|
CALL_STATUSES.FAILED,
|
||||||
|
].includes(status.value)
|
||||||
|
);
|
||||||
|
const isEndedStatus = computed(() =>
|
||||||
|
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
|
||||||
|
);
|
||||||
|
const isOutbound = computed(
|
||||||
|
() => direction.value === CALL_DIRECTIONS.OUTBOUND
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelKey = computed(() => {
|
||||||
|
const s = status.value;
|
||||||
|
|
||||||
|
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s === CALL_STATUSES.RINGING) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s === CALL_STATUSES.NO_ANSWER) {
|
||||||
|
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFailedStatus.value) {
|
||||||
|
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEndedStatus.value) {
|
||||||
|
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtextKey = computed(() => {
|
||||||
|
const s = status.value;
|
||||||
|
|
||||||
|
if (s === CALL_STATUSES.RINGING) {
|
||||||
|
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFailedStatus.value) {
|
||||||
|
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEndedStatus.value) {
|
||||||
|
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||||
|
});
|
||||||
|
|
||||||
|
const bubbleIconName = computed(() => {
|
||||||
|
const s = status.value;
|
||||||
|
|
||||||
|
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'i-ph-phone-outgoing-fill'
|
||||||
|
: 'i-ph-phone-incoming-fill';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFailedStatus.value) {
|
||||||
|
return 'i-ph-phone-x-fill';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ringing/completed/canceled show direction when possible
|
||||||
|
return isOutbound.value
|
||||||
|
? 'i-ph-phone-outgoing-fill'
|
||||||
|
: 'i-ph-phone-incoming-fill';
|
||||||
|
});
|
||||||
|
|
||||||
|
const bubbleIconBg = computed(() => {
|
||||||
|
const s = status.value;
|
||||||
|
|
||||||
|
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||||
|
return 'bg-n-teal-9';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFailedStatus.value) {
|
||||||
|
return 'bg-n-ruby-9';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEndedStatus.value) {
|
||||||
|
return 'bg-n-slate-11';
|
||||||
|
}
|
||||||
|
|
||||||
|
// default (e.g., ringing)
|
||||||
|
return 'bg-n-teal-9 animate-pulse';
|
||||||
|
});
|
||||||
|
|
||||||
|
const listIconColor = computed(() => {
|
||||||
|
const s = status.value;
|
||||||
|
|
||||||
|
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
|
||||||
|
return 'text-n-teal-9';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFailedStatus.value) {
|
||||||
|
return 'text-n-ruby-9';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEndedStatus.value) {
|
||||||
|
return 'text-n-slate-11';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-n-teal-9';
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
labelKey,
|
||||||
|
subtextKey,
|
||||||
|
bubbleIconName,
|
||||||
|
bubbleIconBg,
|
||||||
|
listIconColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ export const FEATURE_FLAGS = {
|
|||||||
CAPTAIN_V2: 'captain_integration_v2',
|
CAPTAIN_V2: 'captain_integration_v2',
|
||||||
SAML: 'saml',
|
SAML: 'saml',
|
||||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||||
COMPANIES: 'companies',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PREMIUM_FEATURES = [
|
export const PREMIUM_FEATURES = [
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const initializeAudioAlerts = user => {
|
|||||||
enable_audio_alerts: audioAlertType,
|
enable_audio_alerts: audioAlertType,
|
||||||
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
|
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
|
||||||
notification_tone: audioAlertTone,
|
notification_tone: audioAlertTone,
|
||||||
// UI Settings can be undefined initially as we don't send the
|
// UI Settings can be undefined initally as we don't send the
|
||||||
// entire payload for the user during the signup process.
|
// entire payload for the user during the signup process.
|
||||||
} = uiSettings || {};
|
} = uiSettings || {};
|
||||||
|
|
||||||
|
|||||||
@@ -579,18 +579,7 @@
|
|||||||
"NO_LABELS_FOUND": "No labels available yet.",
|
"NO_LABELS_FOUND": "No labels available yet.",
|
||||||
"SELECTED_COUNT": "{count} selected",
|
"SELECTED_COUNT": "{count} selected",
|
||||||
"CLEAR_SELECTION": "Clear selection",
|
"CLEAR_SELECTION": "Clear selection",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})"
|
||||||
"DELETE_CONTACTS": "Delete",
|
|
||||||
"DELETE_SUCCESS": "Contacts deleted successfully.",
|
|
||||||
"DELETE_FAILED": "Failed to delete contacts.",
|
|
||||||
"DELETE_DIALOG": {
|
|
||||||
"TITLE": "Delete selected contacts",
|
|
||||||
"SINGULAR_TITLE": "Delete selected contact",
|
|
||||||
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
|
|
||||||
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
|
|
||||||
"CONFIRM_MULTIPLE": "Delete contacts",
|
|
||||||
"CONFIRM_SINGLE": "Delete contact"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"COMPOSE_NEW_CONVERSATION": {
|
"COMPOSE_NEW_CONVERSATION": {
|
||||||
"CONTACT_SEARCH": {
|
"CONTACT_SEARCH": {
|
||||||
|
|||||||
@@ -46,10 +46,6 @@
|
|||||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"NAME": {
|
|
||||||
"LABEL": "Webhook Name",
|
|
||||||
"PLACEHOLDER": "Enter the name of the webhook"
|
|
||||||
},
|
|
||||||
"END_POINT": {
|
"END_POINT": {
|
||||||
"LABEL": "Webhook URL",
|
"LABEL": "Webhook URL",
|
||||||
"PLACEHOLDER": "Example: {webhookExampleURL}",
|
"PLACEHOLDER": "Example: {webhookExampleURL}",
|
||||||
@@ -348,12 +344,6 @@
|
|||||||
"CAPTAIN": {
|
"CAPTAIN": {
|
||||||
"NAME": "Captain",
|
"NAME": "Captain",
|
||||||
"HEADER_KNOW_MORE": "Know more",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"ASSISTANT_SWITCHER": {
|
|
||||||
"ASSISTANTS": "Assistants",
|
|
||||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
|
||||||
"NEW_ASSISTANT": "Create Assistant",
|
|
||||||
"EMPTY_LIST": "No assistants found, please create one to get started"
|
|
||||||
},
|
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
"TITLE": "Copilot",
|
"TITLE": "Copilot",
|
||||||
"TRY_THESE_PROMPTS": "Try these prompts",
|
"TRY_THESE_PROMPTS": "Try these prompts",
|
||||||
@@ -493,7 +483,9 @@
|
|||||||
"NOT_FOUND": "Could not find the assistant. Please try again."
|
"NOT_FOUND": "Could not find the assistant. Please try again."
|
||||||
},
|
},
|
||||||
"SETTINGS": {
|
"SETTINGS": {
|
||||||
"HEADER": "Settings",
|
"BREADCRUMB": {
|
||||||
|
"ASSISTANT": "Assistant"
|
||||||
|
},
|
||||||
"BASIC_SETTINGS": {
|
"BASIC_SETTINGS": {
|
||||||
"TITLE": "Basic settings",
|
"TITLE": "Basic settings",
|
||||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||||
@@ -510,16 +502,15 @@
|
|||||||
"TITLE": "Guardrails",
|
"TITLE": "Guardrails",
|
||||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
|
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
|
||||||
},
|
},
|
||||||
|
"SCENARIOS": {
|
||||||
|
"TITLE": "Scenarios",
|
||||||
|
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
|
||||||
|
},
|
||||||
"RESPONSE_GUIDELINES": {
|
"RESPONSE_GUIDELINES": {
|
||||||
"TITLE": "Response guidelines",
|
"TITLE": "Response guidelines",
|
||||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
|
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"DELETE": {
|
|
||||||
"TITLE": "Delete Assistant",
|
|
||||||
"DESCRIPTION": "This action is permanent. Deleting this assistant will remove it from all connected inboxes and permanently erase all generated knowledge.",
|
|
||||||
"BUTTON_TEXT": "Delete {assistantName}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
@@ -538,6 +529,9 @@
|
|||||||
"GUARDRAILS": {
|
"GUARDRAILS": {
|
||||||
"TITLE": "Guardrails",
|
"TITLE": "Guardrails",
|
||||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Guardrails"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -583,6 +577,9 @@
|
|||||||
"RESPONSE_GUIDELINES": {
|
"RESPONSE_GUIDELINES": {
|
||||||
"TITLE": "Response Guidelines",
|
"TITLE": "Response Guidelines",
|
||||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Response Guidelines"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -628,6 +625,9 @@
|
|||||||
"SCENARIOS": {
|
"SCENARIOS": {
|
||||||
"TITLE": "Scenarios",
|
"TITLE": "Scenarios",
|
||||||
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Scenarios"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -725,6 +725,11 @@
|
|||||||
"NAME": {
|
"NAME": {
|
||||||
"LABEL": "Document Name (Optional)",
|
"LABEL": "Document Name (Optional)",
|
||||||
"PLACEHOLDER": "Enter a name for the document"
|
"PLACEHOLDER": "Enter a name for the document"
|
||||||
|
},
|
||||||
|
"ASSISTANT": {
|
||||||
|
"LABEL": "Assistant",
|
||||||
|
"PLACEHOLDER": "Select the assistant",
|
||||||
|
"ERROR": "The assistant field is required"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
@@ -918,6 +923,11 @@
|
|||||||
"LABEL": "Answer",
|
"LABEL": "Answer",
|
||||||
"PLACEHOLDER": "Enter the answer here",
|
"PLACEHOLDER": "Enter the answer here",
|
||||||
"ERROR": "Please provide a valid answer."
|
"ERROR": "Please provide a valid answer."
|
||||||
|
},
|
||||||
|
"ASSISTANT": {
|
||||||
|
"LABEL": "Assistant",
|
||||||
|
"PLACEHOLDER": "Select an assistant",
|
||||||
|
"ERROR": "Please select an assistant."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
|
|||||||
@@ -53,8 +53,6 @@
|
|||||||
"LAST_7_DAYS": "Last 7 days",
|
"LAST_7_DAYS": "Last 7 days",
|
||||||
"LAST_14_DAYS": "Last 14 days",
|
"LAST_14_DAYS": "Last 14 days",
|
||||||
"LAST_30_DAYS": "Last 30 days",
|
"LAST_30_DAYS": "Last 30 days",
|
||||||
"THIS_MONTH": "This month",
|
|
||||||
"LAST_MONTH": "Last month",
|
|
||||||
"LAST_3_MONTHS": "Last 3 months",
|
"LAST_3_MONTHS": "Last 3 months",
|
||||||
"LAST_6_MONTHS": "Last 6 months",
|
"LAST_6_MONTHS": "Last 6 months",
|
||||||
"LAST_YEAR": "Last year",
|
"LAST_YEAR": "Last year",
|
||||||
|
|||||||
@@ -251,12 +251,6 @@
|
|||||||
"ACCOUNT_SUSPENDED": {
|
"ACCOUNT_SUSPENDED": {
|
||||||
"TITLE": "Account Suspended",
|
"TITLE": "Account Suspended",
|
||||||
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
|
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
|
||||||
},
|
|
||||||
"NO_ACCOUNTS": {
|
|
||||||
"TITLE": "No account found",
|
|
||||||
"MESSAGE_CLOUD": "You are not part of any accounts right now. If you think this is a mistake, please reach out to our support team.",
|
|
||||||
"MESSAGE_SELF_HOSTED": "You are not part of any accounts right now. Please reach out to your administrator.",
|
|
||||||
"LOGOUT": "Log out"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"COMPONENTS": {
|
"COMPONENTS": {
|
||||||
@@ -311,10 +305,6 @@
|
|||||||
"CAPTAIN_DOCUMENTS": "Documents",
|
"CAPTAIN_DOCUMENTS": "Documents",
|
||||||
"CAPTAIN_RESPONSES": "FAQs",
|
"CAPTAIN_RESPONSES": "FAQs",
|
||||||
"CAPTAIN_TOOLS": "Tools",
|
"CAPTAIN_TOOLS": "Tools",
|
||||||
"CAPTAIN_SCENARIOS": "Scenarios",
|
|
||||||
"CAPTAIN_PLAYGROUND": "Playground",
|
|
||||||
"CAPTAIN_INBOXES": "Inboxes",
|
|
||||||
"CAPTAIN_SETTINGS": "Settings",
|
|
||||||
"HOME": "Home",
|
"HOME": "Home",
|
||||||
"AGENTS": "Agents",
|
"AGENTS": "Agents",
|
||||||
"AGENT_BOTS": "Bots",
|
"AGENT_BOTS": "Bots",
|
||||||
|
|||||||
@@ -579,18 +579,7 @@
|
|||||||
"NO_LABELS_FOUND": "No labels available yet.",
|
"NO_LABELS_FOUND": "No labels available yet.",
|
||||||
"SELECTED_COUNT": "{count} selected",
|
"SELECTED_COUNT": "{count} selected",
|
||||||
"CLEAR_SELECTION": "Clear selection",
|
"CLEAR_SELECTION": "Clear selection",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})"
|
||||||
"DELETE_CONTACTS": "حذف",
|
|
||||||
"DELETE_SUCCESS": "Contacts deleted successfully.",
|
|
||||||
"DELETE_FAILED": "Failed to delete contacts.",
|
|
||||||
"DELETE_DIALOG": {
|
|
||||||
"TITLE": "Delete selected contacts",
|
|
||||||
"SINGULAR_TITLE": "Delete selected contact",
|
|
||||||
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
|
|
||||||
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
|
|
||||||
"CONFIRM_MULTIPLE": "Delete contacts",
|
|
||||||
"CONFIRM_SINGLE": "حذف جهة الاتصال"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"COMPOSE_NEW_CONVERSATION": {
|
"COMPOSE_NEW_CONVERSATION": {
|
||||||
"CONTACT_SEARCH": {
|
"CONTACT_SEARCH": {
|
||||||
|
|||||||
@@ -46,10 +46,6 @@
|
|||||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"NAME": {
|
|
||||||
"LABEL": "Webhook Name",
|
|
||||||
"PLACEHOLDER": "Enter the name of the webhook"
|
|
||||||
},
|
|
||||||
"END_POINT": {
|
"END_POINT": {
|
||||||
"LABEL": "رابط Webhook",
|
"LABEL": "رابط Webhook",
|
||||||
"PLACEHOLDER": "Example: {webhookExampleURL}",
|
"PLACEHOLDER": "Example: {webhookExampleURL}",
|
||||||
@@ -348,12 +344,6 @@
|
|||||||
"CAPTAIN": {
|
"CAPTAIN": {
|
||||||
"NAME": "قائد",
|
"NAME": "قائد",
|
||||||
"HEADER_KNOW_MORE": "Know more",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"ASSISTANT_SWITCHER": {
|
|
||||||
"ASSISTANTS": "Assistants",
|
|
||||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
|
||||||
"NEW_ASSISTANT": "Create Assistant",
|
|
||||||
"EMPTY_LIST": "No assistants found, please create one to get started"
|
|
||||||
},
|
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
"TITLE": "Copilot",
|
"TITLE": "Copilot",
|
||||||
"TRY_THESE_PROMPTS": "Try these prompts",
|
"TRY_THESE_PROMPTS": "Try these prompts",
|
||||||
@@ -493,7 +483,9 @@
|
|||||||
"NOT_FOUND": "Could not find the assistant. Please try again."
|
"NOT_FOUND": "Could not find the assistant. Please try again."
|
||||||
},
|
},
|
||||||
"SETTINGS": {
|
"SETTINGS": {
|
||||||
"HEADER": "الإعدادات",
|
"BREADCRUMB": {
|
||||||
|
"ASSISTANT": "Assistant"
|
||||||
|
},
|
||||||
"BASIC_SETTINGS": {
|
"BASIC_SETTINGS": {
|
||||||
"TITLE": "Basic settings",
|
"TITLE": "Basic settings",
|
||||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||||
@@ -510,16 +502,15 @@
|
|||||||
"TITLE": "Guardrails",
|
"TITLE": "Guardrails",
|
||||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
|
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
|
||||||
},
|
},
|
||||||
|
"SCENARIOS": {
|
||||||
|
"TITLE": "Scenarios",
|
||||||
|
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
|
||||||
|
},
|
||||||
"RESPONSE_GUIDELINES": {
|
"RESPONSE_GUIDELINES": {
|
||||||
"TITLE": "Response guidelines",
|
"TITLE": "Response guidelines",
|
||||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
|
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"DELETE": {
|
|
||||||
"TITLE": "Delete Assistant",
|
|
||||||
"DESCRIPTION": "This action is permanent. Deleting this assistant will remove it from all connected inboxes and permanently erase all generated knowledge.",
|
|
||||||
"BUTTON_TEXT": "Delete {assistantName}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
@@ -538,6 +529,9 @@
|
|||||||
"GUARDRAILS": {
|
"GUARDRAILS": {
|
||||||
"TITLE": "Guardrails",
|
"TITLE": "Guardrails",
|
||||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Guardrails"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -583,6 +577,9 @@
|
|||||||
"RESPONSE_GUIDELINES": {
|
"RESPONSE_GUIDELINES": {
|
||||||
"TITLE": "Response Guidelines",
|
"TITLE": "Response Guidelines",
|
||||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Response Guidelines"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -628,6 +625,9 @@
|
|||||||
"SCENARIOS": {
|
"SCENARIOS": {
|
||||||
"TITLE": "Scenarios",
|
"TITLE": "Scenarios",
|
||||||
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Scenarios"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -725,6 +725,11 @@
|
|||||||
"NAME": {
|
"NAME": {
|
||||||
"LABEL": "Document Name (Optional)",
|
"LABEL": "Document Name (Optional)",
|
||||||
"PLACEHOLDER": "Enter a name for the document"
|
"PLACEHOLDER": "Enter a name for the document"
|
||||||
|
},
|
||||||
|
"ASSISTANT": {
|
||||||
|
"LABEL": "Assistant",
|
||||||
|
"PLACEHOLDER": "Select the assistant",
|
||||||
|
"ERROR": "The assistant field is required"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
@@ -918,6 +923,11 @@
|
|||||||
"LABEL": "Answer",
|
"LABEL": "Answer",
|
||||||
"PLACEHOLDER": "Enter the answer here",
|
"PLACEHOLDER": "Enter the answer here",
|
||||||
"ERROR": "Please provide a valid answer."
|
"ERROR": "Please provide a valid answer."
|
||||||
|
},
|
||||||
|
"ASSISTANT": {
|
||||||
|
"LABEL": "Assistant",
|
||||||
|
"PLACEHOLDER": "Select an assistant",
|
||||||
|
"ERROR": "Please select an assistant."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
|
|||||||
@@ -53,8 +53,6 @@
|
|||||||
"LAST_7_DAYS": "آخر 7 أيام",
|
"LAST_7_DAYS": "آخر 7 أيام",
|
||||||
"LAST_14_DAYS": "آخر 14 يوماً",
|
"LAST_14_DAYS": "آخر 14 يوماً",
|
||||||
"LAST_30_DAYS": "آخر 30 يوماً",
|
"LAST_30_DAYS": "آخر 30 يوماً",
|
||||||
"THIS_MONTH": "This month",
|
|
||||||
"LAST_MONTH": "Last month",
|
|
||||||
"LAST_3_MONTHS": "آخر 3 أشهر",
|
"LAST_3_MONTHS": "آخر 3 أشهر",
|
||||||
"LAST_6_MONTHS": "آخر 6 أشهر",
|
"LAST_6_MONTHS": "آخر 6 أشهر",
|
||||||
"LAST_YEAR": "العام الماضي",
|
"LAST_YEAR": "العام الماضي",
|
||||||
|
|||||||
@@ -251,12 +251,6 @@
|
|||||||
"ACCOUNT_SUSPENDED": {
|
"ACCOUNT_SUSPENDED": {
|
||||||
"TITLE": "تم تعليق الحساب",
|
"TITLE": "تم تعليق الحساب",
|
||||||
"MESSAGE": "تم تعليق حسابك. يرجى الاتصال بفريق الدعم للمزيد من المعلومات."
|
"MESSAGE": "تم تعليق حسابك. يرجى الاتصال بفريق الدعم للمزيد من المعلومات."
|
||||||
},
|
|
||||||
"NO_ACCOUNTS": {
|
|
||||||
"TITLE": "No account found",
|
|
||||||
"MESSAGE_CLOUD": "You are not part of any accounts right now. If you think this is a mistake, please reach out to our support team.",
|
|
||||||
"MESSAGE_SELF_HOSTED": "You are not part of any accounts right now. Please reach out to your administrator.",
|
|
||||||
"LOGOUT": "Log out"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"COMPONENTS": {
|
"COMPONENTS": {
|
||||||
@@ -311,10 +305,6 @@
|
|||||||
"CAPTAIN_DOCUMENTS": "Documents",
|
"CAPTAIN_DOCUMENTS": "Documents",
|
||||||
"CAPTAIN_RESPONSES": "FAQs",
|
"CAPTAIN_RESPONSES": "FAQs",
|
||||||
"CAPTAIN_TOOLS": "Tools",
|
"CAPTAIN_TOOLS": "Tools",
|
||||||
"CAPTAIN_SCENARIOS": "Scenarios",
|
|
||||||
"CAPTAIN_PLAYGROUND": "Playground",
|
|
||||||
"CAPTAIN_INBOXES": "قنوات التواصل",
|
|
||||||
"CAPTAIN_SETTINGS": "الإعدادات",
|
|
||||||
"HOME": "الرئيسية",
|
"HOME": "الرئيسية",
|
||||||
"AGENTS": "وكيل الدعم",
|
"AGENTS": "وكيل الدعم",
|
||||||
"AGENT_BOTS": "الروبوتات",
|
"AGENT_BOTS": "الروبوتات",
|
||||||
|
|||||||
@@ -579,18 +579,7 @@
|
|||||||
"NO_LABELS_FOUND": "No labels available yet.",
|
"NO_LABELS_FOUND": "No labels available yet.",
|
||||||
"SELECTED_COUNT": "{count} selected",
|
"SELECTED_COUNT": "{count} selected",
|
||||||
"CLEAR_SELECTION": "Clear selection",
|
"CLEAR_SELECTION": "Clear selection",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})"
|
||||||
"DELETE_CONTACTS": "Delete",
|
|
||||||
"DELETE_SUCCESS": "Contacts deleted successfully.",
|
|
||||||
"DELETE_FAILED": "Failed to delete contacts.",
|
|
||||||
"DELETE_DIALOG": {
|
|
||||||
"TITLE": "Delete selected contacts",
|
|
||||||
"SINGULAR_TITLE": "Delete selected contact",
|
|
||||||
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
|
|
||||||
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
|
|
||||||
"CONFIRM_MULTIPLE": "Delete contacts",
|
|
||||||
"CONFIRM_SINGLE": "Delete contact"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"COMPOSE_NEW_CONVERSATION": {
|
"COMPOSE_NEW_CONVERSATION": {
|
||||||
"CONTACT_SEARCH": {
|
"CONTACT_SEARCH": {
|
||||||
|
|||||||
@@ -46,10 +46,6 @@
|
|||||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"NAME": {
|
|
||||||
"LABEL": "Webhook Name",
|
|
||||||
"PLACEHOLDER": "Enter the name of the webhook"
|
|
||||||
},
|
|
||||||
"END_POINT": {
|
"END_POINT": {
|
||||||
"LABEL": "Webhook URL",
|
"LABEL": "Webhook URL",
|
||||||
"PLACEHOLDER": "Example: {webhookExampleURL}",
|
"PLACEHOLDER": "Example: {webhookExampleURL}",
|
||||||
@@ -348,12 +344,6 @@
|
|||||||
"CAPTAIN": {
|
"CAPTAIN": {
|
||||||
"NAME": "Captain",
|
"NAME": "Captain",
|
||||||
"HEADER_KNOW_MORE": "Know more",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"ASSISTANT_SWITCHER": {
|
|
||||||
"ASSISTANTS": "Assistants",
|
|
||||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
|
||||||
"NEW_ASSISTANT": "Create Assistant",
|
|
||||||
"EMPTY_LIST": "No assistants found, please create one to get started"
|
|
||||||
},
|
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
"TITLE": "Copilot",
|
"TITLE": "Copilot",
|
||||||
"TRY_THESE_PROMPTS": "Try these prompts",
|
"TRY_THESE_PROMPTS": "Try these prompts",
|
||||||
@@ -493,7 +483,9 @@
|
|||||||
"NOT_FOUND": "Could not find the assistant. Please try again."
|
"NOT_FOUND": "Could not find the assistant. Please try again."
|
||||||
},
|
},
|
||||||
"SETTINGS": {
|
"SETTINGS": {
|
||||||
"HEADER": "Settings",
|
"BREADCRUMB": {
|
||||||
|
"ASSISTANT": "Assistant"
|
||||||
|
},
|
||||||
"BASIC_SETTINGS": {
|
"BASIC_SETTINGS": {
|
||||||
"TITLE": "Basic settings",
|
"TITLE": "Basic settings",
|
||||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||||
@@ -510,16 +502,15 @@
|
|||||||
"TITLE": "Guardrails",
|
"TITLE": "Guardrails",
|
||||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
|
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
|
||||||
},
|
},
|
||||||
|
"SCENARIOS": {
|
||||||
|
"TITLE": "Scenarios",
|
||||||
|
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
|
||||||
|
},
|
||||||
"RESPONSE_GUIDELINES": {
|
"RESPONSE_GUIDELINES": {
|
||||||
"TITLE": "Response guidelines",
|
"TITLE": "Response guidelines",
|
||||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
|
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"DELETE": {
|
|
||||||
"TITLE": "Delete Assistant",
|
|
||||||
"DESCRIPTION": "This action is permanent. Deleting this assistant will remove it from all connected inboxes and permanently erase all generated knowledge.",
|
|
||||||
"BUTTON_TEXT": "Delete {assistantName}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
@@ -538,6 +529,9 @@
|
|||||||
"GUARDRAILS": {
|
"GUARDRAILS": {
|
||||||
"TITLE": "Guardrails",
|
"TITLE": "Guardrails",
|
||||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Guardrails"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -583,6 +577,9 @@
|
|||||||
"RESPONSE_GUIDELINES": {
|
"RESPONSE_GUIDELINES": {
|
||||||
"TITLE": "Response Guidelines",
|
"TITLE": "Response Guidelines",
|
||||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Response Guidelines"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -628,6 +625,9 @@
|
|||||||
"SCENARIOS": {
|
"SCENARIOS": {
|
||||||
"TITLE": "Scenarios",
|
"TITLE": "Scenarios",
|
||||||
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"TITLE": "Scenarios"
|
||||||
|
},
|
||||||
"BULK_ACTION": {
|
"BULK_ACTION": {
|
||||||
"SELECTED": "{count} item selected | {count} items selected",
|
"SELECTED": "{count} item selected | {count} items selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
@@ -725,6 +725,11 @@
|
|||||||
"NAME": {
|
"NAME": {
|
||||||
"LABEL": "Document Name (Optional)",
|
"LABEL": "Document Name (Optional)",
|
||||||
"PLACEHOLDER": "Enter a name for the document"
|
"PLACEHOLDER": "Enter a name for the document"
|
||||||
|
},
|
||||||
|
"ASSISTANT": {
|
||||||
|
"LABEL": "Assistant",
|
||||||
|
"PLACEHOLDER": "Select the assistant",
|
||||||
|
"ERROR": "The assistant field is required"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
@@ -918,6 +923,11 @@
|
|||||||
"LABEL": "Answer",
|
"LABEL": "Answer",
|
||||||
"PLACEHOLDER": "Enter the answer here",
|
"PLACEHOLDER": "Enter the answer here",
|
||||||
"ERROR": "Please provide a valid answer."
|
"ERROR": "Please provide a valid answer."
|
||||||
|
},
|
||||||
|
"ASSISTANT": {
|
||||||
|
"LABEL": "Assistant",
|
||||||
|
"PLACEHOLDER": "Select an assistant",
|
||||||
|
"ERROR": "Please select an assistant."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user