Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2b82111a7 | ||
|
|
c5f6844877 | ||
|
|
15547fe2cd | ||
|
|
39243b9e71 | ||
|
|
fb2f5e1d42 | ||
|
|
756aea3554 | ||
|
|
316d116cec | ||
|
|
cfe3061b5d | ||
|
|
aa7e3c2d38 | ||
|
|
3874383698 | ||
|
|
101eca3003 | ||
|
|
61eaa098ae | ||
|
|
9cd7c4ef89 | ||
|
|
f4538ae2c5 | ||
|
|
fd5ac2a8a3 | ||
|
|
6b7180d051 | ||
|
|
ff5440173c | ||
|
|
abb5a4c4d8 | ||
|
|
37da4dd8ca | ||
|
|
4d362da9f0 | ||
|
|
2c2f0547f7 | ||
|
|
c7193c7917 | ||
|
|
d272a64ff7 | ||
|
|
b2cb3717e5 | ||
|
|
00ed074d72 | ||
|
|
7b512bd00e | ||
|
|
8f95fafff4 | ||
|
|
0ad47d87f4 | ||
|
|
e65ea24360 | ||
|
|
b252656984 | ||
|
|
6e397c7571 | ||
|
|
4622560fac | ||
|
|
6632610e78 | ||
|
|
bd732f1fa9 | ||
|
|
67112647e8 | ||
|
|
04c456e0a3 |
@@ -94,6 +94,7 @@ yarn-debug.log*
|
|||||||
.vscode
|
.vscode
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
|
.codex/
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
|
||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
@@ -101,3 +102,4 @@ CLAUDE.local.md
|
|||||||
.histoire
|
.histoire
|
||||||
.pnpm-store/*
|
.pnpm-store/*
|
||||||
local/
|
local/
|
||||||
|
Procfile.worktree
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
- **Setup**: `bundle install && pnpm install`
|
- **Setup**: `bundle install && pnpm install`
|
||||||
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
||||||
|
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
|
||||||
|
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
|
||||||
|
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
|
||||||
|
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
|
||||||
|
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
|
||||||
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
||||||
- **Lint Ruby**: `bundle exec rubocop -a`
|
- **Lint Ruby**: `bundle exec rubocop -a`
|
||||||
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
||||||
@@ -50,6 +55,13 @@
|
|||||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||||
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
|
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
|
||||||
|
|
||||||
|
## Codex Worktree Workflow
|
||||||
|
|
||||||
|
- Use a separate git worktree + branch per task to keep changes isolated.
|
||||||
|
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
|
||||||
|
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
|
||||||
|
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
|
||||||
|
|
||||||
## Commit Messages
|
## Commit Messages
|
||||||
|
|
||||||
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||||
@@ -86,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||||
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||||
|
|
||||||
|
## Branding / White-labeling note
|
||||||
|
|
||||||
|
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
|
||||||
|
|||||||
@@ -191,12 +191,14 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.7.0'
|
gem 'ai-agents'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
gem 'ruby_llm', '>= 1.8.2'
|
gem 'ruby_llm', '>= 1.8.2'
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
|
gem 'cld3', '~> 3.7'
|
||||||
|
|
||||||
# OpenTelemetry for LLM observability
|
# OpenTelemetry for LLM observability
|
||||||
gem 'opentelemetry-sdk'
|
gem 'opentelemetry-sdk'
|
||||||
gem 'opentelemetry-exporter-otlp'
|
gem 'opentelemetry-exporter-otlp'
|
||||||
|
|||||||
+17
-15
@@ -126,8 +126,8 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.7.0)
|
ai-agents (0.9.0)
|
||||||
ruby_llm (~> 1.8.2)
|
ruby_llm (~> 1.9.1)
|
||||||
annotaterb (4.20.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
@@ -186,6 +186,7 @@ GEM
|
|||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
|
cld3 (3.7.0)
|
||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
@@ -297,7 +298,7 @@ GEM
|
|||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
faker (3.2.0)
|
faker (3.2.0)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.13.1)
|
faraday (2.14.1)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
@@ -308,12 +309,12 @@ GEM
|
|||||||
hashie
|
hashie
|
||||||
faraday-multipart (1.0.4)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2)
|
multipart-post (~> 2)
|
||||||
faraday-net_http (3.4.0)
|
faraday-net_http (3.4.2)
|
||||||
net-http (>= 0.5.0)
|
net-http (~> 0.5)
|
||||||
faraday-net_http_persistent (2.1.0)
|
faraday-net_http_persistent (2.1.0)
|
||||||
faraday (~> 2.5)
|
faraday (~> 2.5)
|
||||||
net-http-persistent (~> 4.0)
|
net-http-persistent (~> 4.0)
|
||||||
faraday-retry (2.2.1)
|
faraday-retry (2.4.0)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday_middleware-aws-sigv4 (1.0.1)
|
faraday_middleware-aws-sigv4 (1.0.1)
|
||||||
aws-sigv4 (~> 1.0)
|
aws-sigv4 (~> 1.0)
|
||||||
@@ -464,7 +465,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.13.2)
|
json (2.18.1)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@@ -539,7 +540,7 @@ GEM
|
|||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.1.0)
|
||||||
maxminddb (0.1.22)
|
maxminddb (0.1.22)
|
||||||
meta_request (0.8.5)
|
meta_request (0.8.5)
|
||||||
rack-contrib (>= 1.1, < 3)
|
rack-contrib (>= 1.1, < 3)
|
||||||
@@ -558,12 +559,12 @@ GEM
|
|||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.8.0)
|
multi_xml (0.8.0)
|
||||||
bigdecimal (>= 3.1, < 5)
|
bigdecimal (>= 3.1, < 5)
|
||||||
multipart-post (2.3.0)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
neighbor (0.2.3)
|
neighbor (0.2.3)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
net-http (0.6.0)
|
net-http (0.9.1)
|
||||||
uri
|
uri (>= 0.11.1)
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.20)
|
net-imap (0.4.20)
|
||||||
@@ -824,7 +825,7 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.8.2)
|
ruby_llm (1.9.2)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
@@ -968,7 +969,7 @@ GEM
|
|||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uniform_notifier (1.17.0)
|
uniform_notifier (1.17.0)
|
||||||
uri (1.0.4)
|
uri (1.1.1)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
@@ -1003,7 +1004,7 @@ GEM
|
|||||||
working_hours (1.4.1)
|
working_hours (1.4.1)
|
||||||
activesupport (>= 3.2)
|
activesupport (>= 3.2)
|
||||||
tzinfo
|
tzinfo
|
||||||
zeitwerk (2.6.17)
|
zeitwerk (2.7.4)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
arm64-darwin-20
|
arm64-darwin-20
|
||||||
@@ -1023,7 +1024,7 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.7.0)
|
ai-agents
|
||||||
annotaterb
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
@@ -1037,6 +1038,7 @@ DEPENDENCIES
|
|||||||
bullet
|
bullet
|
||||||
bundle-audit
|
bundle-audit
|
||||||
byebug
|
byebug
|
||||||
|
cld3 (~> 3.7)
|
||||||
climate_control
|
climate_control
|
||||||
commonmarker
|
commonmarker
|
||||||
csv-safe
|
csv-safe
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
|
|
||||||
def transcript
|
def transcript
|
||||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
||||||
|
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
|
||||||
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
|
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
|
||||||
|
|
||||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
if @inbox.present?
|
||||||
|
# Invalidate cache immediately so frontends don't serve stale data
|
||||||
|
# while the async DeleteObjectJob is still in the queue.
|
||||||
|
Current.account.update_cache_key('inbox')
|
||||||
|
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip)
|
||||||
|
end
|
||||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def transcript
|
def transcript
|
||||||
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
|
return head :too_many_requests if conversation.blank?
|
||||||
|
return head :payment_required unless conversation.account.email_transcript_enabled?
|
||||||
|
return head :too_many_requests unless conversation.account.within_email_rate_limit?
|
||||||
|
|
||||||
send_transcript_email
|
send_transcript_email
|
||||||
head :ok
|
head :ok
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.from_email(params[:email])
|
@user = User.from_email(params[:email])
|
||||||
if @user
|
@user&.send_reset_password_instructions
|
||||||
@user.send_reset_password_instructions
|
build_response(I18n.t('messages.reset_password'), 200)
|
||||||
build_response(I18n.t('messages.reset_password_success'), 200)
|
|
||||||
else
|
|
||||||
build_response(I18n.t('messages.reset_password_failure'), 404)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ class CacheEnabledApiClient extends ApiClient {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearDataInCache() {
|
||||||
|
try {
|
||||||
|
await this.dataManager.initDb();
|
||||||
|
await this.dataManager.db.clear(this.cacheModelName);
|
||||||
|
} catch {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async validateCacheKey(cacheKeyFromApi) {
|
async validateCacheKey(cacheKeyFromApi) {
|
||||||
if (!this.dataManager.db) {
|
if (!this.dataManager.db) {
|
||||||
await this.dataManager.initDb();
|
await this.dataManager.initDb();
|
||||||
|
|||||||
-3
@@ -39,7 +39,6 @@ const policyA = withCount({
|
|||||||
description: 'Distributes conversations evenly among available agents',
|
description: 'Distributes conversations evenly among available agents',
|
||||||
assignmentOrder: 'round_robin',
|
assignmentOrder: 'round_robin',
|
||||||
conversationPriority: 'high',
|
conversationPriority: 'high',
|
||||||
enabled: true,
|
|
||||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
@@ -50,7 +49,6 @@ const policyB = withCount({
|
|||||||
description: 'Assigns based on capacity and workload',
|
description: 'Assigns based on capacity and workload',
|
||||||
assignmentOrder: 'capacity_based',
|
assignmentOrder: 'capacity_based',
|
||||||
conversationPriority: 'medium',
|
conversationPriority: 'medium',
|
||||||
enabled: true,
|
|
||||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
@@ -61,7 +59,6 @@ const emptyPolicy = withCount({
|
|||||||
description: 'Policy with no assigned inboxes',
|
description: 'Policy with no assigned inboxes',
|
||||||
assignmentOrder: 'manual',
|
assignmentOrder: 'manual',
|
||||||
conversationPriority: 'low',
|
conversationPriority: 'low',
|
||||||
enabled: false,
|
|
||||||
inboxes: [],
|
inboxes: [],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
|
|||||||
-17
@@ -15,7 +15,6 @@ const props = defineProps({
|
|||||||
assignmentOrder: { type: String, default: '' },
|
assignmentOrder: { type: String, default: '' },
|
||||||
conversationPriority: { type: String, default: '' },
|
conversationPriority: { type: String, default: '' },
|
||||||
assignedInboxCount: { type: Number, default: 0 },
|
assignedInboxCount: { type: Number, default: 0 },
|
||||||
enabled: { type: Boolean, default: false },
|
|
||||||
inboxes: { type: Array, default: () => [] },
|
inboxes: { type: Array, default: () => [] },
|
||||||
isFetchingInboxes: { type: Boolean, default: false },
|
isFetchingInboxes: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
@@ -65,22 +64,6 @@ const handleFetchInboxes = () => {
|
|||||||
{{ name }}
|
{{ name }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
|
||||||
<span
|
|
||||||
class="text-xs"
|
|
||||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
enabled
|
|
||||||
? t(
|
|
||||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<CardPopover
|
<CardPopover
|
||||||
:title="
|
:title="
|
||||||
t(
|
t(
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['delete']);
|
const emit = defineEmits(['delete', 'navigate']);
|
||||||
|
|
||||||
const handleDelete = itemId => {
|
const handleDelete = itemId => {
|
||||||
emit('delete', itemId);
|
emit('delete', itemId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigate = item => {
|
||||||
|
emit('navigate', item);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,7 +51,11 @@ const handleDelete = itemId => {
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 col-span-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
|
||||||
|
@click="handleNavigate(item)"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="item.icon"
|
v-if="item.icon"
|
||||||
:icon="item.icon"
|
:icon="item.icon"
|
||||||
@@ -61,10 +69,16 @@ const handleDelete = itemId => {
|
|||||||
:size="20"
|
:size="20"
|
||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
<span
|
||||||
|
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
|
||||||
|
>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<Icon
|
||||||
|
icon="i-lucide-external-link"
|
||||||
|
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="flex items-start gap-2 col-span-1">
|
<div class="flex items-start gap-2 col-span-1">
|
||||||
<span
|
<span
|
||||||
|
|||||||
+18
-4
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||||
@@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The model value is in seconds (for the backend/DB)
|
||||||
|
// DurationInput works in minutes internally
|
||||||
|
// We need to convert between seconds and minutes
|
||||||
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 3600,
|
default: 3600,
|
||||||
@@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
|||||||
|
|
||||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||||
|
|
||||||
|
// Convert seconds to minutes for DurationInput
|
||||||
|
const windowInMinutes = computed({
|
||||||
|
get() {
|
||||||
|
return Math.floor((fairDistributionWindow.value || 0) / 60);
|
||||||
|
},
|
||||||
|
set(minutes) {
|
||||||
|
fairDistributionWindow.value = minutes * 60;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detect unit based on minutes (converted from seconds)
|
||||||
const detectUnit = minutes => {
|
const detectUnit = minutes => {
|
||||||
const m = Number(minutes) || 0;
|
const m = Number(minutes) || 0;
|
||||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||||
@@ -34,7 +48,7 @@ const detectUnit = minutes => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
windowUnit.value = detectUnit(windowInMinutes.value);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -73,9 +87,9 @@ onMounted(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||||
>
|
>
|
||||||
<!-- allow 10 mins to 999 days -->
|
<!-- allow 10 mins to 999 days (in minutes) -->
|
||||||
<DurationInput
|
<DurationInput
|
||||||
v-model:model-value="fairDistributionWindow"
|
v-model:model-value="windowInMinutes"
|
||||||
v-model:unit="windowUnit"
|
v-model:unit="windowUnit"
|
||||||
:min="10"
|
:min="10"
|
||||||
:max="1438560"
|
:max="1438560"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -16,12 +18,22 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
disabledMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['select']);
|
const emit = defineEmits(['select']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
if (!props.isActive) {
|
if (!props.isActive && !props.disabled) {
|
||||||
emit('select', props.id);
|
emit('select', props.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -29,9 +41,11 @@ const handleChange = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||||
:class="[
|
:class="[
|
||||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||||
|
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||||
|
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||||
]"
|
]"
|
||||||
@click="handleChange"
|
@click="handleChange"
|
||||||
>
|
>
|
||||||
@@ -41,6 +55,7 @@ const handleChange = () => {
|
|||||||
:checked="isActive"
|
:checked="isActive"
|
||||||
:value="id"
|
:value="id"
|
||||||
:name="id"
|
:name="id"
|
||||||
|
:disabled="disabled"
|
||||||
type="radio"
|
type="radio"
|
||||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
@@ -49,11 +64,23 @@ const handleChange = () => {
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex flex-col gap-3 items-start">
|
<div class="flex flex-col gap-3 items-start">
|
||||||
<h3 class="text-sm font-medium text-n-slate-12">
|
<div class="flex items-center gap-2">
|
||||||
{{ label }}
|
<h3 class="text-sm font-medium text-n-slate-12">
|
||||||
</h3>
|
{{ label }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="disabled"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p class="text-sm text-n-slate-11">
|
<p class="text-sm text-n-slate-11">
|
||||||
{{ description }}
|
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-4
@@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
|
|||||||
const description = ref(
|
const description = ref(
|
||||||
'Distributes conversations evenly among available agents'
|
'Distributes conversations evenly among available agents'
|
||||||
);
|
);
|
||||||
const enabled = ref(true);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -19,13 +18,10 @@ const enabled = ref(true);
|
|||||||
<BaseInfo
|
<BaseInfo
|
||||||
v-model:policy-name="policyName"
|
v-model:policy-name="policyName"
|
||||||
v-model:description="description"
|
v-model:description="description"
|
||||||
v-model:enabled="enabled"
|
|
||||||
name-label="Policy Name"
|
name-label="Policy Name"
|
||||||
name-placeholder="Enter policy name"
|
name-placeholder="Enter policy name"
|
||||||
description-label="Description"
|
description-label="Description"
|
||||||
description-placeholder="Enter policy description"
|
description-placeholder="Enter policy description"
|
||||||
status-label="Status"
|
|
||||||
status-placeholder="Active"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useWindowSize } from '@vueuse/core';
|
import { useWindowSize } from '@vueuse/core';
|
||||||
@@ -59,6 +59,23 @@ const isFetchingInboxes = ref(false);
|
|||||||
const isSearching = ref(false);
|
const isSearching = ref(false);
|
||||||
const showComposeNewConversation = ref(false);
|
const showComposeNewConversation = ref(false);
|
||||||
|
|
||||||
|
const formState = reactive({
|
||||||
|
message: '',
|
||||||
|
subject: '',
|
||||||
|
ccEmails: '',
|
||||||
|
bccEmails: '',
|
||||||
|
attachedFiles: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFormState = () => {
|
||||||
|
Object.assign(formState, {
|
||||||
|
subject: '',
|
||||||
|
ccEmails: '',
|
||||||
|
bccEmails: '',
|
||||||
|
attachedFiles: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const contactById = useMapGetter('contacts/getContactById');
|
const contactById = useMapGetter('contacts/getContactById');
|
||||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
@@ -140,12 +157,14 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
|
|
||||||
const handleTargetInbox = inbox => {
|
const handleTargetInbox = inbox => {
|
||||||
targetInbox.value = inbox;
|
targetInbox.value = inbox;
|
||||||
|
if (!inbox) clearFormState();
|
||||||
resetContacts();
|
resetContacts();
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
selectedContact.value = null;
|
selectedContact.value = null;
|
||||||
targetInbox.value = null;
|
targetInbox.value = null;
|
||||||
|
clearFormState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeCompose = () => {
|
const closeCompose = () => {
|
||||||
@@ -160,6 +179,12 @@ const closeCompose = () => {
|
|||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const discardCompose = () => {
|
||||||
|
clearFormState();
|
||||||
|
formState.message = '';
|
||||||
|
closeCompose();
|
||||||
|
};
|
||||||
|
|
||||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||||
try {
|
try {
|
||||||
const data = await store.dispatch('contactConversations/create', {
|
const data = await store.dispatch('contactConversations/create', {
|
||||||
@@ -171,7 +196,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
|||||||
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
||||||
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
||||||
};
|
};
|
||||||
closeCompose();
|
discardCompose();
|
||||||
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
||||||
return true; // Return success
|
return true; // Return success
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -193,7 +218,11 @@ watch(
|
|||||||
(currentContact, previousContact) => {
|
(currentContact, previousContact) => {
|
||||||
if (currentContact && props.contactId) {
|
if (currentContact && props.contactId) {
|
||||||
// Reset on contact change
|
// Reset on contact change
|
||||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
if (currentContact?.id !== previousContact?.id) {
|
||||||
|
clearSelectedContact();
|
||||||
|
clearFormState();
|
||||||
|
formState.message = '';
|
||||||
|
}
|
||||||
|
|
||||||
// First process the contactable inboxes to get the right structure
|
// First process the contactable inboxes to get the right structure
|
||||||
const processedInboxes = processContactableInboxes(
|
const processedInboxes = processContactableInboxes(
|
||||||
@@ -265,6 +294,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
@click.self="onModalBackdropClick"
|
@click.self="onModalBackdropClick"
|
||||||
>
|
>
|
||||||
<ComposeNewConversationForm
|
<ComposeNewConversationForm
|
||||||
|
:form-state="formState"
|
||||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
:contact-id="contactId"
|
:contact-id="contactId"
|
||||||
@@ -285,7 +315,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
@update-target-inbox="handleTargetInbox"
|
@update-target-inbox="handleTargetInbox"
|
||||||
@clear-selected-contact="clearSelectedContact"
|
@clear-selected-contact="clearSelectedContact"
|
||||||
@create-conversation="createConversation"
|
@create-conversation="createConversation"
|
||||||
@discard="closeCompose"
|
@discard="discardCompose"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-3
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, requiredIf } from '@vuelidate/validators';
|
import { required, requiredIf } from '@vuelidate/validators';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
@@ -37,6 +37,7 @@ const props = defineProps({
|
|||||||
contactsUiFlags: { type: Object, default: null },
|
contactsUiFlags: { type: Object, default: null },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
|
formState: { type: Object, required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -57,13 +58,13 @@ const showBccEmailsDropdown = ref(false);
|
|||||||
|
|
||||||
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
|
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
|
||||||
|
|
||||||
const state = reactive({
|
const state = props.formState || {
|
||||||
message: '',
|
message: '',
|
||||||
subject: '',
|
subject: '',
|
||||||
ccEmails: '',
|
ccEmails: '',
|
||||||
bccEmails: '',
|
bccEmails: '',
|
||||||
attachedFiles: [],
|
attachedFiles: [],
|
||||||
});
|
};
|
||||||
|
|
||||||
const inboxTypes = computed(() => ({
|
const inboxTypes = computed(() => ({
|
||||||
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
|||||||
|
|
||||||
import MessageError from './MessageError.vue';
|
import MessageError from './MessageError.vue';
|
||||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Attachment
|
* @typedef {Object} Attachment
|
||||||
@@ -143,6 +144,7 @@ const { t } = useI18n();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||||
|
const { replaceInstallationName } = useBranding();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the message variant based on props
|
* Computes the message variant based on props
|
||||||
@@ -389,13 +391,17 @@ const shouldRenderMessage = computed(() => {
|
|||||||
const isUnsupported = props.contentAttributes?.isUnsupported;
|
const isUnsupported = props.contentAttributes?.isUnsupported;
|
||||||
const isAnIntegrationMessage =
|
const isAnIntegrationMessage =
|
||||||
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
||||||
|
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
|
||||||
|
const hasExternalError = !!props.contentAttributes?.externalError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
hasAttachments ||
|
hasAttachments ||
|
||||||
props.content ||
|
props.content ||
|
||||||
isEmailContentType ||
|
isEmailContentType ||
|
||||||
isUnsupported ||
|
isUnsupported ||
|
||||||
isAnIntegrationMessage
|
isAnIntegrationMessage ||
|
||||||
|
isFailedMessage ||
|
||||||
|
hasExternalError
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,7 +478,7 @@ const avatarInfo = computed(() => {
|
|||||||
|
|
||||||
const avatarTooltip = computed(() => {
|
const avatarTooltip = computed(() => {
|
||||||
if (props.contentAttributes?.externalEcho) {
|
if (props.contentAttributes?.externalEcho) {
|
||||||
return t('CONVERSATION.NATIVE_APP_ADVISORY');
|
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
|
||||||
}
|
}
|
||||||
if (avatarInfo.value.name === '') return '';
|
if (avatarInfo.value.name === '') return '';
|
||||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['retry']);
|
const emit = defineEmits(['retry']);
|
||||||
|
|
||||||
const { orientation, status, createdAt } = useMessageContext();
|
const { orientation, status, createdAt, content, attachments } =
|
||||||
|
useMessageContext();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
|
const canRetry = computed(() => {
|
||||||
|
const hasContent = content.value !== null;
|
||||||
|
const hasAttachments = attachments.value && attachments.value.length > 0;
|
||||||
|
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
@@ -50,6 +51,18 @@ const isRTL = useMapGetter('accounts/isRTL');
|
|||||||
const { width: windowWidth } = useWindowSize();
|
const { width: windowWidth } = useWindowSize();
|
||||||
const isMobile = computed(() => windowWidth.value < 768);
|
const isMobile = computed(() => windowWidth.value < 768);
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAdvancedAssignment = computed(() => {
|
||||||
|
return isFeatureEnabledonAccount.value(
|
||||||
|
accountId.value,
|
||||||
|
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const toggleShortcutModalFn = show => {
|
const toggleShortcutModalFn = show => {
|
||||||
if (show) {
|
if (show) {
|
||||||
emit('openKeyShortcutModal');
|
emit('openKeyShortcutModal');
|
||||||
@@ -584,12 +597,16 @@ const menuItems = computed(() => {
|
|||||||
icon: 'i-lucide-users',
|
icon: 'i-lucide-users',
|
||||||
to: accountScopedRoute('settings_teams_list'),
|
to: accountScopedRoute('settings_teams_list'),
|
||||||
},
|
},
|
||||||
{
|
...(hasAdvancedAssignment.value
|
||||||
name: 'Settings Agent Assignment',
|
? [
|
||||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
{
|
||||||
icon: 'i-lucide-user-cog',
|
name: 'Settings Agent Assignment',
|
||||||
to: accountScopedRoute('assignment_policy_index'),
|
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||||
},
|
icon: 'i-lucide-user-cog',
|
||||||
|
to: accountScopedRoute('assignment_policy_index'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
name: 'Settings Inboxes',
|
name: 'Settings Inboxes',
|
||||||
label: t('SIDEBAR.INBOXES'),
|
label: t('SIDEBAR.INBOXES'),
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const {
|
|||||||
isConversationSelected,
|
isConversationSelected,
|
||||||
onAssignAgent,
|
onAssignAgent,
|
||||||
onAssignLabels,
|
onAssignLabels,
|
||||||
|
onRemoveLabels,
|
||||||
onAssignTeamsForBulk,
|
onAssignTeamsForBulk,
|
||||||
onUpdateConversations,
|
onUpdateConversations,
|
||||||
} = useBulkActions();
|
} = useBulkActions();
|
||||||
@@ -859,6 +860,7 @@ provide('deSelectConversation', deSelectConversation);
|
|||||||
provide('assignAgent', onAssignAgent);
|
provide('assignAgent', onAssignAgent);
|
||||||
provide('assignTeam', onAssignTeam);
|
provide('assignTeam', onAssignTeam);
|
||||||
provide('assignLabels', onAssignLabels);
|
provide('assignLabels', onAssignLabels);
|
||||||
|
provide('removeLabels', onRemoveLabels);
|
||||||
provide('updateConversationStatus', handleResolveConversation);
|
provide('updateConversationStatus', handleResolveConversation);
|
||||||
provide('toggleContextMenu', onContextMenuToggle);
|
provide('toggleContextMenu', onContextMenuToggle);
|
||||||
provide('markAsUnread', markAsUnread);
|
provide('markAsUnread', markAsUnread);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default {
|
|||||||
'assignAgent',
|
'assignAgent',
|
||||||
'assignTeam',
|
'assignTeam',
|
||||||
'assignLabels',
|
'assignLabels',
|
||||||
|
'removeLabels',
|
||||||
'updateConversationStatus',
|
'updateConversationStatus',
|
||||||
'toggleContextMenu',
|
'toggleContextMenu',
|
||||||
'markAsUnread',
|
'markAsUnread',
|
||||||
@@ -63,6 +64,7 @@ export default {
|
|||||||
@assign-agent="assignAgent"
|
@assign-agent="assignAgent"
|
||||||
@assign-team="assignTeam"
|
@assign-team="assignTeam"
|
||||||
@assign-label="assignLabels"
|
@assign-label="assignLabels"
|
||||||
|
@remove-label="removeLabels"
|
||||||
@update-conversation-status="updateConversationStatus"
|
@update-conversation-status="updateConversationStatus"
|
||||||
@context-menu-toggle="toggleContextMenu"
|
@context-menu-toggle="toggleContextMenu"
|
||||||
@mark-as-unread="markAsUnread"
|
@mark-as-unread="markAsUnread"
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import {
|
||||||
|
CONVERSATION_EVENTS,
|
||||||
|
CAPTAIN_EVENTS,
|
||||||
|
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +89,7 @@ const props = defineProps({
|
|||||||
// are triggered except when this flag is true
|
// are triggered except when this flag is true
|
||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
|
conversationId: { type: Number, default: null },
|
||||||
medium: { type: String, default: '' },
|
medium: { type: String, default: '' },
|
||||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||||
focusOnMount: { type: Boolean, default: true },
|
focusOnMount: { type: Boolean, default: true },
|
||||||
@@ -396,7 +400,14 @@ function openFileBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCopilotClick() {
|
function handleCopilotClick() {
|
||||||
showSelectionMenu.value = !showSelectionMenu.value;
|
const isOpening = !showSelectionMenu.value;
|
||||||
|
if (isOpening) {
|
||||||
|
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||||
|
conversationId: props.conversationId,
|
||||||
|
entryPoint: 'inline',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showSelectionMenu.value = isOpening;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event) {
|
function handleClickOutside(event) {
|
||||||
@@ -819,7 +830,13 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="editorRoot" class="relative w-full">
|
<div
|
||||||
|
ref="editorRoot"
|
||||||
|
class="relative w-full"
|
||||||
|
:class="{
|
||||||
|
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<TagAgents
|
<TagAgents
|
||||||
v-if="showUserMentions && isPrivate"
|
v-if="showUserMentions && isPrivate"
|
||||||
:search-key="mentionSearchKey"
|
:search-key="mentionSearchKey"
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
isEditorDisabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'replaceText',
|
'replaceText',
|
||||||
@@ -130,7 +134,7 @@ export default {
|
|||||||
'selectContentTemplate',
|
'selectContentTemplate',
|
||||||
'toggleQuotedReply',
|
'toggleQuotedReply',
|
||||||
],
|
],
|
||||||
setup() {
|
setup(props) {
|
||||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||||
useUISettings();
|
useUISettings();
|
||||||
|
|
||||||
@@ -139,6 +143,9 @@ export default {
|
|||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'$mod+Alt+KeyA': {
|
'$mod+Alt+KeyA': {
|
||||||
action: () => {
|
action: () => {
|
||||||
|
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
|
||||||
|
if (props.isEditorDisabled) return;
|
||||||
|
|
||||||
// TODO: This is really hacky, we need to replace the file picker component with
|
// TODO: This is really hacky, we need to replace the file picker component with
|
||||||
// a custom one, where the logic and the component markup is isolated.
|
// a custom one, where the logic and the component markup is isolated.
|
||||||
// Once we have the custom component, we can remove the hacky logic below.
|
// Once we have the custom component, we can remove the hacky logic below.
|
||||||
@@ -146,7 +153,7 @@ export default {
|
|||||||
const uploadTriggerButton = document.querySelector(
|
const uploadTriggerButton = document.querySelector(
|
||||||
'#conversationAttachment'
|
'#conversationAttachment'
|
||||||
);
|
);
|
||||||
uploadTriggerButton.click();
|
if (uploadTriggerButton) uploadTriggerButton.click();
|
||||||
},
|
},
|
||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
@@ -177,9 +184,11 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
showAttachButton() {
|
showAttachButton() {
|
||||||
|
if (this.isEditorDisabled) return false;
|
||||||
return this.showFileUpload || this.isNote;
|
return this.showFileUpload || this.isNote;
|
||||||
},
|
},
|
||||||
showAudioRecorderButton() {
|
showAudioRecorderButton() {
|
||||||
|
if (this.isEditorDisabled) return false;
|
||||||
if (this.isALineChannel) {
|
if (this.isALineChannel) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -197,6 +206,7 @@ export default {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
showAudioPlayStopButton() {
|
showAudioPlayStopButton() {
|
||||||
|
if (this.isEditorDisabled) return false;
|
||||||
return this.showAudioRecorder && this.isRecordingAudio;
|
return this.showAudioRecorder && this.isRecordingAudio;
|
||||||
},
|
},
|
||||||
isInstagramDM() {
|
isInstagramDM() {
|
||||||
@@ -236,6 +246,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
showMessageSignatureButton() {
|
showMessageSignatureButton() {
|
||||||
|
if (this.isEditorDisabled) return false;
|
||||||
return !this.isOnPrivateNote;
|
return !this.isOnPrivateNote;
|
||||||
},
|
},
|
||||||
sendWithSignature() {
|
sendWithSignature() {
|
||||||
@@ -280,6 +291,7 @@ export default {
|
|||||||
<div class="flex justify-between p-3" :class="wrapClass">
|
<div class="flex justify-between p-3" :class="wrapClass">
|
||||||
<div class="left-wrap">
|
<div class="left-wrap">
|
||||||
<NextButton
|
<NextButton
|
||||||
|
v-if="!isEditorDisabled"
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||||
icon="i-ph-smiley-sticker"
|
icon="i-ph-smiley-sticker"
|
||||||
slate
|
slate
|
||||||
@@ -288,6 +300,7 @@ export default {
|
|||||||
@click="toggleEmojiPicker"
|
@click="toggleEmojiPicker"
|
||||||
/>
|
/>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
|
v-if="showAttachButton"
|
||||||
ref="uploadRef"
|
ref="uploadRef"
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||||
input-id="conversationAttachment"
|
input-id="conversationAttachment"
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||||
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||||
|
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import EditorModeToggle from './EditorModeToggle.vue';
|
import EditorModeToggle from './EditorModeToggle.vue';
|
||||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||||
@@ -31,6 +33,14 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
isEditorDisabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
isMessageLengthReachingThreshold: {
|
isMessageLengthReachingThreshold: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: () => false,
|
default: () => false,
|
||||||
@@ -69,7 +79,14 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleCopilotMenu = () => {
|
const toggleCopilotMenu = () => {
|
||||||
showCopilotMenu.value = !showCopilotMenu.value;
|
const isOpening = !showCopilotMenu.value;
|
||||||
|
if (isOpening) {
|
||||||
|
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||||
|
conversationId: props.conversationId,
|
||||||
|
entryPoint: 'top_panel',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showCopilotMenu.value = isOpening;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickOutside = () => {
|
const handleClickOutside = () => {
|
||||||
@@ -144,7 +161,7 @@ export default {
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<NextButton
|
<NextButton
|
||||||
ghost
|
ghost
|
||||||
:disabled="disabled"
|
:disabled="disabled || isEditorDisabled"
|
||||||
:class="{
|
:class="{
|
||||||
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
|
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
|
||||||
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
|
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const emit = defineEmits([
|
|||||||
'contextMenuToggle',
|
'contextMenuToggle',
|
||||||
'assignAgent',
|
'assignAgent',
|
||||||
'assignLabel',
|
'assignLabel',
|
||||||
|
'removeLabel',
|
||||||
'assignTeam',
|
'assignTeam',
|
||||||
'markAsUnread',
|
'markAsUnread',
|
||||||
'markAsRead',
|
'markAsRead',
|
||||||
@@ -203,7 +204,10 @@ const onAssignAgent = agent => {
|
|||||||
|
|
||||||
const onAssignLabel = label => {
|
const onAssignLabel = label => {
|
||||||
emit('assignLabel', [label.title], [props.chat.id]);
|
emit('assignLabel', [label.title], [props.chat.id]);
|
||||||
closeContextMenu();
|
};
|
||||||
|
|
||||||
|
const onRemoveLabel = label => {
|
||||||
|
emit('removeLabel', [label.title], [props.chat.id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssignTeam = team => {
|
const onAssignTeam = team => {
|
||||||
@@ -379,11 +383,13 @@ const deleteConversation = () => {
|
|||||||
:priority="chat.priority"
|
:priority="chat.priority"
|
||||||
:chat-id="chat.id"
|
:chat-id="chat.id"
|
||||||
:has-unread-messages="hasUnread"
|
:has-unread-messages="hasUnread"
|
||||||
|
:conversation-labels="chat.labels"
|
||||||
:conversation-url="conversationPath"
|
:conversation-url="conversationPath"
|
||||||
:allowed-options="allowedContextMenuOptions"
|
:allowed-options="allowedContextMenuOptions"
|
||||||
@update-conversation="onUpdateConversation"
|
@update-conversation="onUpdateConversation"
|
||||||
@assign-agent="onAssignAgent"
|
@assign-agent="onAssignAgent"
|
||||||
@assign-label="onAssignLabel"
|
@assign-label="onAssignLabel"
|
||||||
|
@remove-label="onRemoveLabel"
|
||||||
@assign-team="onAssignTeam"
|
@assign-team="onAssignTeam"
|
||||||
@mark-as-unread="markAsUnread"
|
@mark-as-unread="markAsUnread"
|
||||||
@mark-as-read="markAsRead"
|
@mark-as-read="markAsRead"
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ import {
|
|||||||
truncatePreviewText,
|
truncatePreviewText,
|
||||||
appendQuotedTextToMessage,
|
appendQuotedTextToMessage,
|
||||||
} from 'dashboard/helper/quotedEmailHelper';
|
} from 'dashboard/helper/quotedEmailHelper';
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import {
|
||||||
|
CONVERSATION_EVENTS,
|
||||||
|
CAPTAIN_EVENTS,
|
||||||
|
} from '../../../helper/AnalyticsHelper/events';
|
||||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
@@ -136,6 +139,7 @@ export default {
|
|||||||
newConversationModalActive: false,
|
newConversationModalActive: false,
|
||||||
showArticleSearchPopover: false,
|
showArticleSearchPopover: false,
|
||||||
hasRecordedAudio: false,
|
hasRecordedAudio: false,
|
||||||
|
copilotAcceptedMessages: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -195,6 +199,11 @@ export default {
|
|||||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||||
},
|
},
|
||||||
messagePlaceHolder() {
|
messagePlaceHolder() {
|
||||||
|
if (this.isEditorDisabled) {
|
||||||
|
return this.isAWhatsAppChannel
|
||||||
|
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
|
||||||
|
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
||||||
|
}
|
||||||
return this.isPrivate
|
return this.isPrivate
|
||||||
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
||||||
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
|
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
|
||||||
@@ -206,6 +215,7 @@ export default {
|
|||||||
return this.maxLength - this.message.length;
|
return this.maxLength - this.message.length;
|
||||||
},
|
},
|
||||||
isReplyButtonDisabled() {
|
isReplyButtonDisabled() {
|
||||||
|
if (this.isEditorDisabled) return true;
|
||||||
if (this.isATwitterInbox) return true;
|
if (this.isATwitterInbox) return true;
|
||||||
if (this.hasAttachments || this.hasRecordedAudio) return false;
|
if (this.hasAttachments || this.hasRecordedAudio) return false;
|
||||||
|
|
||||||
@@ -414,6 +424,13 @@ export default {
|
|||||||
isDefaultEditorMode() {
|
isDefaultEditorMode() {
|
||||||
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
||||||
},
|
},
|
||||||
|
isEditorDisabled() {
|
||||||
|
return (
|
||||||
|
this.isAWhatsAppChannel &&
|
||||||
|
!this.isOnPrivateNote &&
|
||||||
|
!this.currentChat.can_reply
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
currentChat(conversation, oldConversation) {
|
currentChat(conversation, oldConversation) {
|
||||||
@@ -508,6 +525,24 @@ export default {
|
|||||||
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
|
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getDraftKey(
|
||||||
|
conversationId = this.conversationIdByRoute,
|
||||||
|
replyType = this.replyType
|
||||||
|
) {
|
||||||
|
return `draft-${conversationId}-${replyType}`;
|
||||||
|
},
|
||||||
|
getCopilotAcceptedMessage(replyType = this.replyType) {
|
||||||
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||||
|
return this.copilotAcceptedMessages[key] || '';
|
||||||
|
},
|
||||||
|
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||||
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||||
|
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||||
|
},
|
||||||
|
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||||
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||||
|
delete this.copilotAcceptedMessages[key];
|
||||||
|
},
|
||||||
handleInsert(article) {
|
handleInsert(article) {
|
||||||
const { url, title } = article;
|
const { url, title } = article;
|
||||||
// Removing empty lines from the title
|
// Removing empty lines from the title
|
||||||
@@ -559,7 +594,7 @@ export default {
|
|||||||
},
|
},
|
||||||
saveDraft(conversationId, replyType) {
|
saveDraft(conversationId, replyType) {
|
||||||
if (this.message || this.message === '') {
|
if (this.message || this.message === '') {
|
||||||
const key = `draft-${conversationId}-${replyType}`;
|
const key = this.getDraftKey(conversationId, replyType);
|
||||||
const draftToSave = trimContent(this.message || '');
|
const draftToSave = trimContent(this.message || '');
|
||||||
|
|
||||||
this.$store.dispatch('draftMessages/set', {
|
this.$store.dispatch('draftMessages/set', {
|
||||||
@@ -574,7 +609,7 @@ export default {
|
|||||||
},
|
},
|
||||||
getFromDraft() {
|
getFromDraft() {
|
||||||
if (this.conversationIdByRoute) {
|
if (this.conversationIdByRoute) {
|
||||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
const key = this.getDraftKey();
|
||||||
const messageFromStore =
|
const messageFromStore =
|
||||||
this.$store.getters['draftMessages/get'](key) || '';
|
this.$store.getters['draftMessages/get'](key) || '';
|
||||||
|
|
||||||
@@ -597,7 +632,7 @@ export default {
|
|||||||
},
|
},
|
||||||
removeFromDraft() {
|
removeFromDraft() {
|
||||||
if (this.conversationIdByRoute) {
|
if (this.conversationIdByRoute) {
|
||||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
const key = this.getDraftKey();
|
||||||
this.$store.dispatch('draftMessages/delete', { key });
|
this.$store.dispatch('draftMessages/delete', { key });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -655,6 +690,9 @@ export default {
|
|||||||
// Don't handle paste if compose new conversation modal is open
|
// Don't handle paste if compose new conversation modal is open
|
||||||
if (this.newConversationModalActive) return;
|
if (this.newConversationModalActive) return;
|
||||||
|
|
||||||
|
// Don't handle paste if editor is disabled
|
||||||
|
if (this.isEditorDisabled) return;
|
||||||
|
|
||||||
// Filter valid files (non-zero size)
|
// Filter valid files (non-zero size)
|
||||||
Array.from(e.clipboardData.files)
|
Array.from(e.clipboardData.files)
|
||||||
.filter(file => file.size > 0)
|
.filter(file => file.size > 0)
|
||||||
@@ -708,6 +746,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.showMentions) {
|
if (!this.showMentions) {
|
||||||
|
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
|
||||||
const isOnWhatsApp =
|
const isOnWhatsApp =
|
||||||
this.isATwilioWhatsAppChannel ||
|
this.isATwilioWhatsAppChannel ||
|
||||||
this.isAWhatsAppCloudChannel ||
|
this.isAWhatsAppCloudChannel ||
|
||||||
@@ -717,10 +756,17 @@ export default {
|
|||||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||||
const isOnInstagram = this.isAnInstagramChannel;
|
const isOnInstagram = this.isAnInstagramChannel;
|
||||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||||
this.sendMessageAsMultipleMessages(this.message);
|
this.sendMessageAsMultipleMessages(
|
||||||
|
this.message,
|
||||||
|
copilotAcceptedMessage
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const messagePayload = this.getMessagePayload(this.message);
|
const messagePayload = this.getMessagePayload(this.message);
|
||||||
this.sendMessage(messagePayload);
|
this.sendMessage(
|
||||||
|
messagePayload,
|
||||||
|
this.message,
|
||||||
|
copilotAcceptedMessage
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isPrivate) {
|
if (!this.isPrivate) {
|
||||||
@@ -732,13 +778,53 @@ export default {
|
|||||||
this.$emit('update:popOutReplyBox', false);
|
this.$emit('update:popOutReplyBox', false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendMessageAsMultipleMessages(message) {
|
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||||
const messages = this.getMultipleMessagesPayload(message);
|
const messages = this.getMultipleMessagesPayload(message);
|
||||||
messages.forEach(messagePayload => {
|
messages.forEach(messagePayload => {
|
||||||
this.sendMessage(messagePayload);
|
this.sendMessage(
|
||||||
|
messagePayload,
|
||||||
|
messagePayload.message || '',
|
||||||
|
copilotAcceptedMessage
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
sendMessageAnalyticsData(isPrivate) {
|
sendMessageAnalyticsData(
|
||||||
|
isPrivate,
|
||||||
|
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
|
||||||
|
) {
|
||||||
|
const normalizeForComparison = message => {
|
||||||
|
let normalizedMessage = message || '';
|
||||||
|
|
||||||
|
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
|
||||||
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
|
this.channelType,
|
||||||
|
this.inbox?.medium || ''
|
||||||
|
);
|
||||||
|
normalizedMessage = removeSignature(
|
||||||
|
normalizedMessage,
|
||||||
|
this.messageSignature,
|
||||||
|
effectiveChannelType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimContent(normalizedMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedAcceptedMessage = normalizeForComparison(
|
||||||
|
copilotAcceptedMessage
|
||||||
|
);
|
||||||
|
const normalizedEditorMessage = normalizeForComparison(editorMessage);
|
||||||
|
|
||||||
|
if (normalizedAcceptedMessage && normalizedEditorMessage) {
|
||||||
|
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
|
||||||
|
conversationId: this.conversationIdByRoute,
|
||||||
|
channelType: this.channelType,
|
||||||
|
editedBeforeSend:
|
||||||
|
normalizedAcceptedMessage !== normalizedEditorMessage,
|
||||||
|
isPrivate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Analytics data for message signature is enabled or not in channels
|
// Analytics data for message signature is enabled or not in channels
|
||||||
return isPrivate
|
return isPrivate
|
||||||
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
|
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
|
||||||
@@ -772,7 +858,11 @@ export default {
|
|||||||
this.confirmOnSendReply();
|
this.confirmOnSendReply();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async sendMessage(messagePayload) {
|
async sendMessage(
|
||||||
|
messagePayload,
|
||||||
|
editorMessage = '',
|
||||||
|
copilotAcceptedMessage = ''
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await this.$store.dispatch(
|
await this.$store.dispatch(
|
||||||
'createPendingMessageAndSend',
|
'createPendingMessageAndSend',
|
||||||
@@ -781,7 +871,10 @@ export default {
|
|||||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||||
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
||||||
this.removeFromDraft();
|
this.removeFromDraft();
|
||||||
this.sendMessageAnalyticsData(messagePayload.private);
|
this.sendMessageAnalyticsData(messagePayload.private, {
|
||||||
|
editorMessage,
|
||||||
|
copilotAcceptedMessage,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||||
@@ -855,6 +948,7 @@ export default {
|
|||||||
},
|
},
|
||||||
clearMessage() {
|
clearMessage() {
|
||||||
this.message = '';
|
this.message = '';
|
||||||
|
this.clearCopilotAcceptedMessage();
|
||||||
if (this.sendWithSignature && !this.isPrivate) {
|
if (this.sendWithSignature && !this.isPrivate) {
|
||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
const effectiveChannelType = getEffectiveChannelType(
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
@@ -1119,7 +1213,9 @@ export default {
|
|||||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||||
},
|
},
|
||||||
onSubmitCopilotReply() {
|
onSubmitCopilotReply() {
|
||||||
this.message = this.copilot.accept();
|
const acceptedMessage = this.copilot.accept();
|
||||||
|
this.message = acceptedMessage;
|
||||||
|
this.setCopilotAcceptedMessage(acceptedMessage);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1130,11 +1226,13 @@ export default {
|
|||||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||||
<ReplyTopPanel
|
<ReplyTopPanel
|
||||||
:mode="replyType"
|
:mode="replyType"
|
||||||
|
:conversation-id="conversationId"
|
||||||
:is-reply-restricted="isReplyRestricted"
|
:is-reply-restricted="isReplyRestricted"
|
||||||
:disabled="
|
:disabled="
|
||||||
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
||||||
showAudioRecorderEditor
|
showAudioRecorderEditor
|
||||||
"
|
"
|
||||||
|
:is-editor-disabled="isEditorDisabled"
|
||||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||||
:characters-remaining="charactersRemaining"
|
:characters-remaining="charactersRemaining"
|
||||||
:popout-reply-box="popOutReplyBox"
|
:popout-reply-box="popOutReplyBox"
|
||||||
@@ -1204,12 +1302,14 @@ export default {
|
|||||||
<WootMessageEditor
|
<WootMessageEditor
|
||||||
v-else-if="!showAudioRecorderEditor"
|
v-else-if="!showAudioRecorderEditor"
|
||||||
v-model="message"
|
v-model="message"
|
||||||
|
:conversation-id="conversationId"
|
||||||
:editor-id="editorStateId"
|
:editor-id="editorStateId"
|
||||||
class="input popover-prosemirror-menu"
|
class="input popover-prosemirror-menu"
|
||||||
:is-private="isOnPrivateNote"
|
:is-private="isOnPrivateNote"
|
||||||
:placeholder="messagePlaceHolder"
|
:placeholder="messagePlaceHolder"
|
||||||
:update-selection-with="updateEditorSelectionWith"
|
:update-selection-with="updateEditorSelectionWith"
|
||||||
:min-height="4"
|
:min-height="4"
|
||||||
|
:disabled="isEditorDisabled"
|
||||||
enable-variables
|
enable-variables
|
||||||
:variables="messageVariables"
|
:variables="messageVariables"
|
||||||
:signature="messageSignature"
|
:signature="messageSignature"
|
||||||
@@ -1285,6 +1385,7 @@ export default {
|
|||||||
:is-recording-audio="isRecordingAudio"
|
:is-recording-audio="isRecordingAudio"
|
||||||
:is-send-disabled="isReplyButtonDisabled"
|
:is-send-disabled="isReplyButtonDisabled"
|
||||||
:is-note="isPrivate"
|
:is-note="isPrivate"
|
||||||
|
:is-editor-disabled="isEditorDisabled"
|
||||||
:on-file-upload="onFileUpload"
|
:on-file-upload="onFileUpload"
|
||||||
:on-send="onSendReply"
|
:on-send="onSendReply"
|
||||||
:conversation-type="conversationType"
|
:conversation-type="conversationType"
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ const assignedAgent = computed({
|
|||||||
},
|
},
|
||||||
set(agent) {
|
set(agent) {
|
||||||
const agentId = agent ? agent.id : null;
|
const agentId = agent ? agent.id : null;
|
||||||
store.dispatch('setCurrentChatAssignee', agent);
|
store.dispatch('setCurrentChatAssignee', {
|
||||||
|
conversationId: currentChat.value?.id,
|
||||||
|
assignee: agent,
|
||||||
|
});
|
||||||
store.dispatch('assignAgent', {
|
store.dispatch('assignAgent', {
|
||||||
conversationId: currentChat.value?.id,
|
conversationId: currentChat.value?.id,
|
||||||
agentId,
|
agentId,
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
conversationLabels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
conversationUrl: {
|
conversationUrl: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
@@ -70,6 +74,7 @@ export default {
|
|||||||
'assignAgent',
|
'assignAgent',
|
||||||
'assignTeam',
|
'assignTeam',
|
||||||
'assignLabel',
|
'assignLabel',
|
||||||
|
'removeLabel',
|
||||||
'deleteConversation',
|
'deleteConversation',
|
||||||
'close',
|
'close',
|
||||||
],
|
],
|
||||||
@@ -334,8 +339,16 @@ export default {
|
|||||||
v-for="label in labels"
|
v-for="label in labels"
|
||||||
:key="label.id"
|
:key="label.id"
|
||||||
:option="generateMenuLabelConfig(label, 'label')"
|
:option="generateMenuLabelConfig(label, 'label')"
|
||||||
variant="label"
|
:variant="
|
||||||
@click.stop="$emit('assignLabel', label)"
|
conversationLabels.includes(label.title)
|
||||||
|
? 'label-assigned'
|
||||||
|
: 'label'
|
||||||
|
"
|
||||||
|
@click.stop="
|
||||||
|
conversationLabels.includes(label.title)
|
||||||
|
? $emit('removeLabel', label)
|
||||||
|
: $emit('assignLabel', label)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</MenuItemWithSubmenu>
|
</MenuItemWithSubmenu>
|
||||||
<MenuItemWithSubmenu
|
<MenuItemWithSubmenu
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
option: {
|
option: {
|
||||||
@@ -22,7 +23,9 @@ defineProps({
|
|||||||
class="flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="variant === 'label' && option.color"
|
v-if="
|
||||||
|
(variant === 'label' || variant === 'label-assigned') && option.color
|
||||||
|
"
|
||||||
class="label-pill flex-shrink-0"
|
class="label-pill flex-shrink-0"
|
||||||
:style="{ backgroundColor: option.color }"
|
:style="{ backgroundColor: option.color }"
|
||||||
/>
|
/>
|
||||||
@@ -37,6 +40,11 @@ defineProps({
|
|||||||
<p class="menu-label truncate min-w-0 flex-1">
|
<p class="menu-label truncate min-w-0 flex-1">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</p>
|
</p>
|
||||||
|
<Icon
|
||||||
|
v-if="variant === 'label-assigned'"
|
||||||
|
icon="i-lucide-check"
|
||||||
|
class="flex-shrink-0 size-3.5 mr-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -2,6 +2,7 @@
|
|||||||
// components
|
// components
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
|
|
||||||
// composables
|
// composables
|
||||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||||
@@ -34,8 +35,9 @@ export default {
|
|||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { captainTasksEnabled } = useCaptain();
|
const { captainTasksEnabled } = useCaptain();
|
||||||
|
const { replaceInstallationName } = useBranding();
|
||||||
|
|
||||||
return { captainTasksEnabled };
|
return { captainTasksEnabled, replaceInstallationName };
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -228,7 +230,9 @@ export default {
|
|||||||
<div class="sender--info has-tooltip" data-original-title="null">
|
<div class="sender--info has-tooltip" data-original-title="null">
|
||||||
<Avatar
|
<Avatar
|
||||||
v-tooltip.top="{
|
v-tooltip.top="{
|
||||||
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
|
content: replaceInstallationName(
|
||||||
|
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
|
||||||
|
),
|
||||||
delay: { show: 600, hide: 0 },
|
delay: { show: 600, hide: 0 },
|
||||||
hideOnClick: true,
|
hideOnClick: true,
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const CAPTAIN_ERROR_TYPES = Object.freeze({
|
||||||
|
ABORTED: 'aborted',
|
||||||
|
API_ERROR: 'api_error',
|
||||||
|
HTTP_PREFIX: 'http_',
|
||||||
|
ABORT_ERROR: 'AbortError',
|
||||||
|
CANCELED_ERROR: 'CanceledError',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
|
||||||
|
EMPTY_RESPONSE: 'empty_response',
|
||||||
|
EXCEPTION: 'exception',
|
||||||
|
});
|
||||||
@@ -102,6 +102,28 @@ export function useBulkActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only used in context menu
|
||||||
|
async function onRemoveLabels(labelsToRemove, conversationId = null) {
|
||||||
|
try {
|
||||||
|
await store.dispatch('bulkActions/process', {
|
||||||
|
type: 'Conversation',
|
||||||
|
ids: conversationId || selectedConversations.value,
|
||||||
|
labels: {
|
||||||
|
remove: labelsToRemove,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useAlert(
|
||||||
|
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||||
|
labelName: labelsToRemove[0],
|
||||||
|
conversationId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onAssignTeamsForBulk(team) {
|
async function onAssignTeamsForBulk(team) {
|
||||||
try {
|
try {
|
||||||
await store.dispatch('bulkActions/process', {
|
await store.dispatch('bulkActions/process', {
|
||||||
@@ -189,6 +211,7 @@ export function useBulkActions() {
|
|||||||
isConversationSelected,
|
isConversationSelected,
|
||||||
onAssignAgent,
|
onAssignAgent,
|
||||||
onAssignLabels,
|
onAssignLabels,
|
||||||
|
onRemoveLabels,
|
||||||
onAssignTeamsForBulk,
|
onAssignTeamsForBulk,
|
||||||
onUpdateConversations,
|
onUpdateConversations,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||||
|
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
|
||||||
|
|
||||||
export function useCaptain() {
|
export function useCaptain() {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -69,7 +70,10 @@ export function useCaptain() {
|
|||||||
* @param {Error} error - The error object from the API call.
|
* @param {Error} error - The error object from the API call.
|
||||||
*/
|
*/
|
||||||
const handleAPIError = error => {
|
const handleAPIError = error => {
|
||||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
if (
|
||||||
|
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||||
|
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -78,6 +82,24 @@ export function useCaptain() {
|
|||||||
useAlert(errorMessage);
|
useAlert(errorMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies API error types for downstream analytics.
|
||||||
|
* @param {Error} error
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const getErrorType = error => {
|
||||||
|
if (
|
||||||
|
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||||
|
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||||
|
) {
|
||||||
|
return CAPTAIN_ERROR_TYPES.ABORTED;
|
||||||
|
}
|
||||||
|
if (error.response?.status) {
|
||||||
|
return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
|
||||||
|
}
|
||||||
|
return CAPTAIN_ERROR_TYPES.API_ERROR;
|
||||||
|
};
|
||||||
|
|
||||||
// === Task Methods ===
|
// === Task Methods ===
|
||||||
/**
|
/**
|
||||||
* Rewrites content with a specific operation.
|
* Rewrites content with a specific operation.
|
||||||
@@ -103,7 +125,7 @@ export function useCaptain() {
|
|||||||
return { message: generatedMessage, followUpContext };
|
return { message: generatedMessage, followUpContext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAPIError(error);
|
handleAPIError(error);
|
||||||
return { message: '' };
|
return { message: '', errorType: getErrorType(error) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +147,7 @@ export function useCaptain() {
|
|||||||
return { message: generatedMessage, followUpContext };
|
return { message: generatedMessage, followUpContext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAPIError(error);
|
handleAPIError(error);
|
||||||
return { message: '' };
|
return { message: '', errorType: getErrorType(error) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,7 +169,7 @@ export function useCaptain() {
|
|||||||
return { message: generatedMessage, followUpContext };
|
return { message: generatedMessage, followUpContext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAPIError(error);
|
handleAPIError(error);
|
||||||
return { message: '' };
|
return { message: '', errorType: getErrorType(error) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,7 +193,11 @@ export function useCaptain() {
|
|||||||
return { message: generatedMessage, followUpContext: updatedContext };
|
return { message: generatedMessage, followUpContext: updatedContext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAPIError(error);
|
handleAPIError(error);
|
||||||
return { message: '', followUpContext };
|
return {
|
||||||
|
message: '',
|
||||||
|
followUpContext,
|
||||||
|
errorType: getErrorType(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { useCaptain } from 'dashboard/composables/useCaptain';
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import {
|
||||||
|
CAPTAIN_ERROR_TYPES,
|
||||||
|
CAPTAIN_GENERATION_FAILURE_REASONS,
|
||||||
|
} from 'dashboard/composables/captain/constants';
|
||||||
|
|
||||||
// Actions that map to REWRITE events (with operation attribute)
|
// Actions that map to REWRITE events (with operation attribute)
|
||||||
const REWRITE_ACTIONS = [
|
const REWRITE_ACTIONS = [
|
||||||
@@ -52,6 +56,20 @@ function buildPayload(action, conversationId, followUpCount = undefined) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackGenerationFailure({
|
||||||
|
action,
|
||||||
|
conversationId,
|
||||||
|
followUpCount = undefined,
|
||||||
|
stage,
|
||||||
|
reason,
|
||||||
|
}) {
|
||||||
|
useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
|
||||||
|
...buildPayload(action, conversationId, followUpCount),
|
||||||
|
stage,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for managing Copilot reply generation state and actions.
|
* Composable for managing Copilot reply generation state and actions.
|
||||||
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
||||||
@@ -146,7 +164,8 @@ export function useCopilotReply() {
|
|||||||
|
|
||||||
// Reset without tracking dismiss (starting new action)
|
// Reset without tracking dismiss (starting new action)
|
||||||
reset(false);
|
reset(false);
|
||||||
abortController.value = new AbortController();
|
const requestController = new AbortController();
|
||||||
|
abortController.value = requestController;
|
||||||
isGenerating.value = true;
|
isGenerating.value = true;
|
||||||
isContentReady.value = false;
|
isContentReady.value = false;
|
||||||
currentAction.value = action;
|
currentAction.value = action;
|
||||||
@@ -154,28 +173,66 @@ export function useCopilotReply() {
|
|||||||
trackedConversationId.value = conversationId.value;
|
trackedConversationId.value = conversationId.value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { message: content, followUpContext: newContext } =
|
const {
|
||||||
await processEvent(action, data, {
|
message: content,
|
||||||
signal: abortController.value.signal,
|
followUpContext: newContext,
|
||||||
});
|
errorType,
|
||||||
|
} = await processEvent(action, data, {
|
||||||
|
signal: requestController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!abortController.value?.signal.aborted) {
|
if (requestController.signal.aborted) return;
|
||||||
generatedContent.value = content;
|
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||||
followUpContext.value = newContext;
|
if (abortController.value === requestController) {
|
||||||
if (content) {
|
isGenerating.value = false;
|
||||||
showEditor.value = true;
|
|
||||||
// Track "Used" event on successful generation
|
|
||||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
|
||||||
useTrack(
|
|
||||||
CAPTAIN_EVENTS[eventKey],
|
|
||||||
buildPayload(action, trackedConversationId.value)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
isGenerating.value = false;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
if (!abortController.value?.signal.aborted) {
|
generatedContent.value = content;
|
||||||
isGenerating.value = false;
|
followUpContext.value = newContext;
|
||||||
|
if (content) {
|
||||||
|
showEditor.value = true;
|
||||||
|
// Track "Used" event on successful generation
|
||||||
|
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||||
|
useTrack(
|
||||||
|
CAPTAIN_EVENTS[eventKey],
|
||||||
|
buildPayload(action, trackedConversationId.value)
|
||||||
|
);
|
||||||
|
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||||
|
trackGenerationFailure({
|
||||||
|
action,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
stage: 'initial',
|
||||||
|
reason: errorType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trackGenerationFailure({
|
||||||
|
action,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
stage: 'initial',
|
||||||
|
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isGenerating.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
requestController.signal.aborted ||
|
||||||
|
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||||
|
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackGenerationFailure({
|
||||||
|
action,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
stage: 'initial',
|
||||||
|
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
|
||||||
|
});
|
||||||
|
isGenerating.value = false;
|
||||||
|
} finally {
|
||||||
|
if (abortController.value === requestController) {
|
||||||
|
abortController.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +244,8 @@ export function useCopilotReply() {
|
|||||||
async function sendFollowUp(message) {
|
async function sendFollowUp(message) {
|
||||||
if (!followUpContext.value || !message.trim()) return;
|
if (!followUpContext.value || !message.trim()) return;
|
||||||
|
|
||||||
abortController.value = new AbortController();
|
const requestController = new AbortController();
|
||||||
|
abortController.value = requestController;
|
||||||
isGenerating.value = true;
|
isGenerating.value = true;
|
||||||
isContentReady.value = false;
|
isContentReady.value = false;
|
||||||
|
|
||||||
@@ -198,24 +256,65 @@ export function useCopilotReply() {
|
|||||||
followUpCount.value += 1;
|
followUpCount.value += 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { message: content, followUpContext: updatedContext } =
|
const {
|
||||||
await followUp({
|
message: content,
|
||||||
followUpContext: followUpContext.value,
|
followUpContext: updatedContext,
|
||||||
message,
|
errorType,
|
||||||
signal: abortController.value.signal,
|
} = await followUp({
|
||||||
});
|
followUpContext: followUpContext.value,
|
||||||
|
message,
|
||||||
|
signal: requestController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!abortController.value?.signal.aborted) {
|
if (requestController.signal.aborted) return;
|
||||||
if (content) {
|
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||||
generatedContent.value = content;
|
if (abortController.value === requestController) {
|
||||||
followUpContext.value = updatedContext;
|
isGenerating.value = false;
|
||||||
showEditor.value = true;
|
|
||||||
}
|
}
|
||||||
isGenerating.value = false;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
if (!abortController.value?.signal.aborted) {
|
if (content) {
|
||||||
isGenerating.value = false;
|
generatedContent.value = content;
|
||||||
|
followUpContext.value = updatedContext;
|
||||||
|
showEditor.value = true;
|
||||||
|
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||||
|
trackGenerationFailure({
|
||||||
|
action: currentAction.value,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
followUpCount: followUpCount.value,
|
||||||
|
stage: 'follow_up',
|
||||||
|
reason: errorType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trackGenerationFailure({
|
||||||
|
action: currentAction.value,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
followUpCount: followUpCount.value,
|
||||||
|
stage: 'follow_up',
|
||||||
|
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isGenerating.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
requestController.signal.aborted ||
|
||||||
|
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||||
|
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackGenerationFailure({
|
||||||
|
action: currentAction.value,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
followUpCount: followUpCount.value,
|
||||||
|
stage: 'follow_up',
|
||||||
|
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
|
||||||
|
});
|
||||||
|
isGenerating.value = false;
|
||||||
|
} finally {
|
||||||
|
if (abortController.value === requestController) {
|
||||||
|
abortController.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const FEATURE_FLAGS = {
|
|||||||
AGENT_BOTS: 'agent_bots',
|
AGENT_BOTS: 'agent_bots',
|
||||||
AGENT_MANAGEMENT: 'agent_management',
|
AGENT_MANAGEMENT: 'agent_management',
|
||||||
ASSIGNMENT_V2: 'assignment_v2',
|
ASSIGNMENT_V2: 'assignment_v2',
|
||||||
|
ADVANCED_ASSIGNMENT: 'advanced_assignment',
|
||||||
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
||||||
AUTOMATIONS: 'automations',
|
AUTOMATIONS: 'automations',
|
||||||
CAMPAIGNS: 'campaigns',
|
CAMPAIGNS: 'campaigns',
|
||||||
@@ -56,4 +57,5 @@ export const PREMIUM_FEATURES = [
|
|||||||
FEATURE_FLAGS.HELP_CENTER,
|
FEATURE_FLAGS.HELP_CENTER,
|
||||||
FEATURE_FLAGS.SAML,
|
FEATURE_FLAGS.SAML,
|
||||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
|
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
|
||||||
|
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export const PORTALS_EVENTS = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const CAPTAIN_EVENTS = Object.freeze({
|
export const CAPTAIN_EVENTS = Object.freeze({
|
||||||
|
// Editor funnel events
|
||||||
|
EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
|
||||||
|
GENERATION_FAILED: 'Captain: Generation failed',
|
||||||
|
AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
|
||||||
|
|
||||||
// Rewrite events (with operation attribute in payload)
|
// Rewrite events (with operation attribute in payload)
|
||||||
REWRITE_USED: 'Captain: Rewrite used',
|
REWRITE_USED: 'Captain: Rewrite used',
|
||||||
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
||||||
|
|||||||
@@ -174,6 +174,10 @@
|
|||||||
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
|
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
|
||||||
"FAILED": "Couldn't assign label. Please try again."
|
"FAILED": "Couldn't assign label. Please try again."
|
||||||
},
|
},
|
||||||
|
"LABEL_REMOVAL": {
|
||||||
|
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
|
||||||
|
"FAILED": "Couldn't remove label. Please try again."
|
||||||
|
},
|
||||||
"TEAM_ASSIGNMENT": {
|
"TEAM_ASSIGNMENT": {
|
||||||
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
|
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
|
||||||
"FAILED": "Couldn't assign team. Please try again."
|
"FAILED": "Couldn't assign team. Please try again."
|
||||||
@@ -186,6 +190,8 @@
|
|||||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||||
|
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||||
|
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||||
"CLICK_HERE": "Click here to update",
|
"CLICK_HERE": "Click here to update",
|
||||||
|
|||||||
@@ -766,6 +766,53 @@
|
|||||||
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
|
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
|
||||||
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
|
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
|
||||||
},
|
},
|
||||||
|
"ASSIGNMENT": {
|
||||||
|
"TITLE": "Conversation Assignment",
|
||||||
|
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
|
||||||
|
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
|
||||||
|
"DEFAULT_RULES_TITLE": "Default assignment rules",
|
||||||
|
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
|
||||||
|
"DEFAULT_RULE_1": "Earliest created conversations first",
|
||||||
|
"DEFAULT_RULE_2": "Round robin distribution",
|
||||||
|
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
|
||||||
|
"USING_POLICY": "Using custom assignment policy for this inbox",
|
||||||
|
"CUSTOMIZE_POLICY": "Customize with assignment policy",
|
||||||
|
"DELETE_POLICY": "Delete policy",
|
||||||
|
"POLICY_LABEL": "Assignment policy",
|
||||||
|
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
|
||||||
|
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
|
||||||
|
"POLICY_STATUS": {
|
||||||
|
"ACTIVE": "Active",
|
||||||
|
"INACTIVE": "Inactive"
|
||||||
|
},
|
||||||
|
"PRIORITY": {
|
||||||
|
"EARLIEST_CREATED": "Earliest created",
|
||||||
|
"LONGEST_WAITING": "Longest waiting"
|
||||||
|
},
|
||||||
|
"METHOD": {
|
||||||
|
"ROUND_ROBIN": "Round robin",
|
||||||
|
"BALANCED": "Balanced assignment"
|
||||||
|
},
|
||||||
|
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
|
||||||
|
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
|
||||||
|
"DEFAULT_POLICY_LINKED": "Default policy linked",
|
||||||
|
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
|
||||||
|
"LINK_EXISTING_POLICY": "Link existing policy",
|
||||||
|
"CREATE_NEW_POLICY": "Create new policy",
|
||||||
|
"NO_POLICIES": "No assignment policies found",
|
||||||
|
"VIEW_ALL_POLICIES": "View all policies",
|
||||||
|
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
|
||||||
|
"LINK_SUCCESS": "Assignment policy linked successfully",
|
||||||
|
"LINK_ERROR": "Failed to link assignment policy"
|
||||||
|
},
|
||||||
|
"ASSIGNMENT_POLICY": {
|
||||||
|
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
|
||||||
|
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"CONFIRM_DELETE": "Delete",
|
||||||
|
"DELETE_SUCCESS": "Assignment policy removed successfully",
|
||||||
|
"DELETE_ERROR": "Failed to remove assignment policy"
|
||||||
|
},
|
||||||
"FACEBOOK_REAUTHORIZE": {
|
"FACEBOOK_REAUTHORIZE": {
|
||||||
"TITLE": "Reauthorize",
|
"TITLE": "Reauthorize",
|
||||||
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
|
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
|
||||||
|
|||||||
@@ -694,7 +694,8 @@
|
|||||||
"CREATE_BUTTON": "Create policy",
|
"CREATE_BUTTON": "Create policy",
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "Assignment policy created successfully",
|
"SUCCESS_MESSAGE": "Assignment policy created successfully",
|
||||||
"ERROR_MESSAGE": "Failed to create assignment policy"
|
"ERROR_MESSAGE": "Failed to create assignment policy",
|
||||||
|
"INBOX_LINKED": "Inbox has been linked to the policy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
@@ -708,6 +709,12 @@
|
|||||||
"CONFIRM_BUTTON_LABEL": "Continue",
|
"CONFIRM_BUTTON_LABEL": "Continue",
|
||||||
"CANCEL_BUTTON_LABEL": "Cancel"
|
"CANCEL_BUTTON_LABEL": "Cancel"
|
||||||
},
|
},
|
||||||
|
"INBOX_LINK_PROMPT": {
|
||||||
|
"TITLE": "Link inbox to policy",
|
||||||
|
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
|
||||||
|
"LINK_BUTTON": "Link inbox",
|
||||||
|
"CANCEL_BUTTON": "Skip"
|
||||||
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
|
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
|
||||||
"ERROR_MESSAGE": "Failed to update assignment policy"
|
"ERROR_MESSAGE": "Failed to update assignment policy"
|
||||||
@@ -746,7 +753,9 @@
|
|||||||
},
|
},
|
||||||
"BALANCED": {
|
"BALANCED": {
|
||||||
"LABEL": "Balanced",
|
"LABEL": "Balanced",
|
||||||
"DESCRIPTION": "Assign conversations based on available capacity."
|
"DESCRIPTION": "Assign conversations based on available capacity.",
|
||||||
|
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
|
||||||
|
"PREMIUM_BADGE": "Premium"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ASSIGNMENT_PRIORITY": {
|
"ASSIGNMENT_PRIORITY": {
|
||||||
@@ -832,6 +841,20 @@
|
|||||||
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
|
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
|
||||||
"ERROR_MESSAGE": "Failed to remove agent from policy"
|
"ERROR_MESSAGE": "Failed to remove agent from policy"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"INBOX_LIMIT_API": {
|
||||||
|
"ADD": {
|
||||||
|
"SUCCESS_MESSAGE": "Inbox limit added successfully",
|
||||||
|
"ERROR_MESSAGE": "Failed to add inbox limit"
|
||||||
|
},
|
||||||
|
"UPDATE": {
|
||||||
|
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Failed to update inbox limit"
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
|
||||||
|
"ERROR_MESSAGE": "Failed to delete inbox limit"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"FORM": {
|
"FORM": {
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ export default {
|
|||||||
},
|
},
|
||||||
set(agent) {
|
set(agent) {
|
||||||
const agentId = agent ? agent.id : null;
|
const agentId = agent ? agent.id : null;
|
||||||
this.$store.dispatch('setCurrentChatAssignee', agent);
|
this.$store.dispatch('setCurrentChatAssignee', {
|
||||||
|
conversationId: this.currentChat.id,
|
||||||
|
assignee: agent,
|
||||||
|
});
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('assignAgent', {
|
.dispatch('assignAgent', {
|
||||||
conversationId: this.currentChat.id,
|
conversationId: this.currentChat.id,
|
||||||
|
|||||||
@@ -1,54 +1,81 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
import SettingsLayout from '../SettingsLayout.vue';
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
|
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const agentAssignments = computed(() => [
|
const accountId = computed(() => Number(route.params.accountId));
|
||||||
{
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
key: 'agent_assignment_policy_index',
|
'accounts/isFeatureEnabledonAccount'
|
||||||
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
|
);
|
||||||
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
|
|
||||||
features: [
|
const agentAssignments = computed(() => {
|
||||||
{
|
const assignments = [
|
||||||
icon: 'i-lucide-circle-fading-arrow-up',
|
{
|
||||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
|
key: 'agent_assignment_policy_index',
|
||||||
},
|
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
|
||||||
{
|
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
|
||||||
icon: 'i-lucide-scale',
|
features: [
|
||||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
|
{
|
||||||
},
|
icon: 'i-lucide-circle-fading-arrow-up',
|
||||||
{
|
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
|
||||||
icon: 'i-lucide-inbox',
|
},
|
||||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
|
{
|
||||||
},
|
icon: 'i-lucide-scale',
|
||||||
],
|
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'agent_capacity_policy_index',
|
icon: 'i-lucide-inbox',
|
||||||
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
|
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
|
||||||
description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'),
|
},
|
||||||
features: [
|
],
|
||||||
{
|
},
|
||||||
icon: 'i-lucide-glass-water',
|
];
|
||||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
|
|
||||||
},
|
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled
|
||||||
{
|
// advanced_assignment identifies premium users
|
||||||
icon: 'i-lucide-circle-minus',
|
const hasAssignmentV2 = isFeatureEnabledonAccount.value(
|
||||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
|
accountId.value,
|
||||||
},
|
'assignment_v2'
|
||||||
{
|
);
|
||||||
icon: 'i-lucide-users-round',
|
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
|
||||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
|
accountId.value,
|
||||||
},
|
'advanced_assignment'
|
||||||
],
|
);
|
||||||
},
|
|
||||||
]);
|
if (hasAssignmentV2 && hasAdvancedAssignment) {
|
||||||
|
assignments.push({
|
||||||
|
key: 'agent_capacity_policy_index',
|
||||||
|
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
|
||||||
|
description: t(
|
||||||
|
'ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'
|
||||||
|
),
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-glass-water',
|
||||||
|
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-circle-minus',
|
||||||
|
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-users-round',
|
||||||
|
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments;
|
||||||
|
});
|
||||||
|
|
||||||
const handleClick = key => {
|
const handleClick = key => {
|
||||||
router.push({ name: key });
|
router.push({ name: key });
|
||||||
|
|||||||
+3
-3
@@ -62,7 +62,7 @@ export default {
|
|||||||
name: 'agent_capacity_policy_index',
|
name: 'agent_capacity_policy_index',
|
||||||
component: AgentCapacityIndex,
|
component: AgentCapacityIndex,
|
||||||
meta: {
|
meta: {
|
||||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||||
permissions: ['administrator'],
|
permissions: ['administrator'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -71,7 +71,7 @@ export default {
|
|||||||
name: 'agent_capacity_policy_create',
|
name: 'agent_capacity_policy_create',
|
||||||
component: AgentCapacityCreate,
|
component: AgentCapacityCreate,
|
||||||
meta: {
|
meta: {
|
||||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||||
permissions: ['administrator'],
|
permissions: ['administrator'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -80,7 +80,7 @@ export default {
|
|||||||
name: 'agent_capacity_policy_edit',
|
name: 'agent_capacity_policy_edit',
|
||||||
component: AgentCapacityEdit,
|
component: AgentCapacityEdit,
|
||||||
meta: {
|
meta: {
|
||||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||||
permissions: ['administrator'],
|
permissions: ['administrator'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+46
-13
@@ -2,13 +2,14 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||||
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -16,20 +17,50 @@ const { t } = useI18n();
|
|||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
|
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
|
||||||
|
|
||||||
const breadcrumbItems = computed(() => [
|
const inboxIdFromQuery = computed(() => {
|
||||||
{
|
const id = route.query.inboxId;
|
||||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
|
return id ? Number(id) : null;
|
||||||
routeName: 'agent_assignment_policy_index',
|
});
|
||||||
},
|
|
||||||
{
|
const breadcrumbItems = computed(() => {
|
||||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
|
if (inboxIdFromQuery.value) {
|
||||||
},
|
return [
|
||||||
]);
|
{
|
||||||
|
label: t('INBOX_MGMT.SETTINGS'),
|
||||||
|
routeName: 'settings_inbox_show',
|
||||||
|
params: { inboxId: inboxIdFromQuery.value },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
|
||||||
|
routeName: 'agent_assignment_policy_index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const handleBreadcrumbClick = item => {
|
const handleBreadcrumbClick = item => {
|
||||||
router.push({
|
if (item.params) {
|
||||||
name: item.routeName,
|
const accountId = route.params.accountId;
|
||||||
});
|
const inboxId = item.params.inboxId;
|
||||||
|
// Navigate using explicit path to ensure tab parameter is included
|
||||||
|
router.push(
|
||||||
|
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push({
|
||||||
|
name: item.routeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async formState => {
|
const handleSubmit = async formState => {
|
||||||
@@ -45,6 +76,8 @@ const handleSubmit = async formState => {
|
|||||||
params: {
|
params: {
|
||||||
id: policy.id,
|
id: policy.id,
|
||||||
},
|
},
|
||||||
|
// Pass inboxId to edit page to show link prompt
|
||||||
|
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(
|
useAlert(
|
||||||
|
|||||||
+110
-17
@@ -14,6 +14,7 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
|||||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||||
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
||||||
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
|
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
|
||||||
|
import InboxLinkDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue';
|
||||||
|
|
||||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
||||||
|
|
||||||
@@ -36,13 +37,46 @@ const confirmInboxDialogRef = ref(null);
|
|||||||
// Store the policy linked to the inbox when adding a new inbox
|
// Store the policy linked to the inbox when adding a new inbox
|
||||||
const inboxLinkedPolicy = ref(null);
|
const inboxLinkedPolicy = ref(null);
|
||||||
|
|
||||||
const breadcrumbItems = computed(() => [
|
// Inbox linking prompt from create flow
|
||||||
{
|
const inboxIdFromQuery = computed(() => {
|
||||||
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
const id = route.query.inboxId;
|
||||||
routeName: 'agent_assignment_policy_index',
|
return id ? Number(id) : null;
|
||||||
},
|
});
|
||||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
|
||||||
]);
|
const suggestedInbox = computed(() => {
|
||||||
|
if (!inboxIdFromQuery.value || !inboxes.value) return null;
|
||||||
|
return inboxes.value.find(inbox => inbox.id === inboxIdFromQuery.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLinkingInbox = ref(false);
|
||||||
|
|
||||||
|
const dismissInboxLinkPrompt = () => {
|
||||||
|
router.replace({
|
||||||
|
name: route.name,
|
||||||
|
params: route.params,
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbItems = computed(() => {
|
||||||
|
if (inboxIdFromQuery.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('INBOX_MGMT.SETTINGS'),
|
||||||
|
routeName: 'settings_inbox_show',
|
||||||
|
params: { inboxId: inboxIdFromQuery.value },
|
||||||
|
},
|
||||||
|
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
||||||
|
routeName: 'agent_assignment_policy_index',
|
||||||
|
},
|
||||||
|
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const buildInboxList = allInboxes =>
|
const buildInboxList = allInboxes =>
|
||||||
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||||
@@ -66,22 +100,48 @@ const inboxList = computed(() =>
|
|||||||
const formData = computed(() => ({
|
const formData = computed(() => ({
|
||||||
name: selectedPolicy.value?.name || '',
|
name: selectedPolicy.value?.name || '',
|
||||||
description: selectedPolicy.value?.description || '',
|
description: selectedPolicy.value?.description || '',
|
||||||
enabled: selectedPolicy.value?.enabled || false,
|
enabled: true,
|
||||||
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
|
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
|
||||||
conversationPriority:
|
conversationPriority:
|
||||||
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
||||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
|
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
|
||||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
|
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleDeleteInbox = inboxId =>
|
const handleDeleteInbox = async inboxId => {
|
||||||
store.dispatch('assignmentPolicies/removeInboxPolicy', {
|
try {
|
||||||
policyId: selectedPolicy.value?.id,
|
await store.dispatch('assignmentPolicies/removeInboxPolicy', {
|
||||||
inboxId,
|
policyId: selectedPolicy.value?.id,
|
||||||
});
|
inboxId,
|
||||||
|
});
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.SUCCESS_MESSAGE`));
|
||||||
|
} catch {
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBreadcrumbClick = ({ routeName }) =>
|
const handleBreadcrumbClick = ({ routeName, params }) => {
|
||||||
router.push({ name: routeName });
|
if (params) {
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
const inboxId = params.inboxId;
|
||||||
|
// Navigate using explicit path to ensure tab parameter is included
|
||||||
|
router.push(
|
||||||
|
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push({ name: routeName });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigateToInbox = inbox => {
|
||||||
|
router.push({
|
||||||
|
name: 'settings_inbox_show',
|
||||||
|
params: {
|
||||||
|
accountId: route.params.accountId,
|
||||||
|
inboxId: inbox.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const setInboxPolicy = async (inboxId, policyId) => {
|
const setInboxPolicy = async (inboxId, policyId) => {
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +182,26 @@ const handleAddInbox = async inbox => {
|
|||||||
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
|
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLinkSuggestedInbox = async () => {
|
||||||
|
if (!suggestedInbox.value) return;
|
||||||
|
|
||||||
|
isLinkingInbox.value = true;
|
||||||
|
const inbox = {
|
||||||
|
id: suggestedInbox.value.id,
|
||||||
|
name: suggestedInbox.value.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleAddInbox(inbox);
|
||||||
|
|
||||||
|
// Clear the query param after linking
|
||||||
|
router.replace({
|
||||||
|
name: route.name,
|
||||||
|
params: route.params,
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
isLinkingInbox.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirmAddInbox = async inboxId => {
|
const handleConfirmAddInbox = async inboxId => {
|
||||||
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
|
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
|
||||||
|
|
||||||
@@ -155,6 +235,11 @@ const handleSubmit = async formState => {
|
|||||||
const fetchPolicyData = async () => {
|
const fetchPolicyData = async () => {
|
||||||
if (!routeId.value) return;
|
if (!routeId.value) return;
|
||||||
|
|
||||||
|
// Fetch inboxes if not already loaded (needed for inbox link prompt)
|
||||||
|
if (!inboxes.value?.length) {
|
||||||
|
store.dispatch('inboxes/get');
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch policy if not available
|
// Fetch policy if not available
|
||||||
if (!selectedPolicy.value?.id)
|
if (!selectedPolicy.value?.id)
|
||||||
await store.dispatch('assignmentPolicies/show', routeId.value);
|
await store.dispatch('assignmentPolicies/show', routeId.value);
|
||||||
@@ -186,6 +271,7 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
|||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@add-inbox="handleAddInbox"
|
@add-inbox="handleAddInbox"
|
||||||
@delete-inbox="handleDeleteInbox"
|
@delete-inbox="handleDeleteInbox"
|
||||||
|
@navigate-to-inbox="handleNavigateToInbox"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -193,5 +279,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
|||||||
ref="confirmInboxDialogRef"
|
ref="confirmInboxDialogRef"
|
||||||
@add="handleConfirmAddInbox"
|
@add="handleConfirmAddInbox"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InboxLinkDialog
|
||||||
|
:inbox="suggestedInbox"
|
||||||
|
:is-linking="isLinkingInbox"
|
||||||
|
@link="handleLinkSuggestedInbox"
|
||||||
|
@dismiss="dismissInboxLinkPrompt"
|
||||||
|
/>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+54
-29
@@ -92,43 +92,68 @@ const formData = computed(() => ({
|
|||||||
const handleBreadcrumbClick = ({ routeName }) =>
|
const handleBreadcrumbClick = ({ routeName }) =>
|
||||||
router.push({ name: routeName });
|
router.push({ name: routeName });
|
||||||
|
|
||||||
const handleDeleteUser = agentId => {
|
const handleDeleteUser = async agentId => {
|
||||||
store.dispatch('agentCapacityPolicies/removeUser', {
|
try {
|
||||||
policyId: selectedPolicyId.value,
|
await store.dispatch('agentCapacityPolicies/removeUser', {
|
||||||
userId: agentId,
|
policyId: selectedPolicyId.value,
|
||||||
});
|
userId: agentId,
|
||||||
|
});
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.SUCCESS_MESSAGE`));
|
||||||
|
} catch {
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddUser = agent => {
|
const handleAddUser = async agent => {
|
||||||
store.dispatch('agentCapacityPolicies/addUser', {
|
try {
|
||||||
policyId: selectedPolicyId.value,
|
await store.dispatch('agentCapacityPolicies/addUser', {
|
||||||
userData: { id: agent.id, capacity: 20 },
|
policyId: selectedPolicyId.value,
|
||||||
});
|
userData: { id: agent.id, capacity: 20 },
|
||||||
|
});
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.SUCCESS_MESSAGE`));
|
||||||
|
} catch {
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteInboxLimit = limitId => {
|
const handleDeleteInboxLimit = async limitId => {
|
||||||
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
try {
|
||||||
policyId: selectedPolicyId.value,
|
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
||||||
limitId,
|
policyId: selectedPolicyId.value,
|
||||||
});
|
limitId,
|
||||||
|
});
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.SUCCESS_MESSAGE`));
|
||||||
|
} catch {
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddInboxLimit = limit => {
|
const handleAddInboxLimit = async limit => {
|
||||||
store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
try {
|
||||||
policyId: selectedPolicyId.value,
|
await store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
||||||
limitData: {
|
policyId: selectedPolicyId.value,
|
||||||
inboxId: limit.inboxId,
|
limitData: {
|
||||||
conversationLimit: limit.conversationLimit,
|
inboxId: limit.inboxId,
|
||||||
},
|
conversationLimit: limit.conversationLimit,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.SUCCESS_MESSAGE`));
|
||||||
|
} catch {
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLimitChange = limit => {
|
const handleLimitChange = async limit => {
|
||||||
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
try {
|
||||||
policyId: selectedPolicyId.value,
|
await store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
||||||
limitId: limit.id,
|
policyId: selectedPolicyId.value,
|
||||||
limitData: { conversationLimit: limit.conversationLimit },
|
limitId: limit.id,
|
||||||
});
|
limitData: { conversationLimit: limit.conversationLimit },
|
||||||
|
});
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.SUCCESS_MESSAGE`));
|
||||||
|
} catch {
|
||||||
|
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async formState => {
|
const handleSubmit = async formState => {
|
||||||
|
|||||||
+42
-16
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||||
@@ -23,7 +24,6 @@ const props = defineProps({
|
|||||||
default: () => ({
|
default: () => ({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
enabled: false,
|
|
||||||
assignmentOrder: ROUND_ROBIN,
|
assignmentOrder: ROUND_ROBIN,
|
||||||
conversationPriority: EARLIEST_CREATED,
|
conversationPriority: EARLIEST_CREATED,
|
||||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||||
@@ -61,18 +61,24 @@ const emit = defineEmits([
|
|||||||
'submit',
|
'submit',
|
||||||
'addInbox',
|
'addInbox',
|
||||||
'deleteInbox',
|
'deleteInbox',
|
||||||
|
'navigateToInbox',
|
||||||
'validationChange',
|
'validationChange',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isEnterprise } = useConfig();
|
const route = useRoute();
|
||||||
|
|
||||||
|
const accountId = computed(() => Number(route.params.accountId));
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
enabled: false,
|
enabled: true,
|
||||||
assignmentOrder: ROUND_ROBIN,
|
assignmentOrder: ROUND_ROBIN,
|
||||||
conversationPriority: EARLIEST_CREATED,
|
conversationPriority: EARLIEST_CREATED,
|
||||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||||
@@ -83,20 +89,42 @@ const validationState = ref({
|
|||||||
isValid: false,
|
isValid: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createOption = (type, key, stateKey) => ({
|
const createOption = (
|
||||||
|
type,
|
||||||
|
key,
|
||||||
|
stateKey,
|
||||||
|
disabled = false,
|
||||||
|
disabledMessage = ''
|
||||||
|
) => ({
|
||||||
key,
|
key,
|
||||||
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
|
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
|
||||||
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
|
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
|
||||||
isActive: state[stateKey] === key,
|
isActive: state[stateKey] === key,
|
||||||
|
disabled,
|
||||||
|
disabledMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const assignmentOrderOptions = computed(() => {
|
const assignmentOrderOptions = computed(() => {
|
||||||
const options = OPTIONS.ORDER.filter(
|
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
|
||||||
key => isEnterprise || key !== 'balanced'
|
accountId.value,
|
||||||
);
|
'advanced_assignment'
|
||||||
return options.map(key =>
|
|
||||||
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return OPTIONS.ORDER.map(key => {
|
||||||
|
const isBalanced = key === 'balanced';
|
||||||
|
const disabled = isBalanced && !hasAdvancedAssignment;
|
||||||
|
const disabledMessage = disabled
|
||||||
|
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return createOption(
|
||||||
|
'ASSIGNMENT_ORDER',
|
||||||
|
key,
|
||||||
|
'assignmentOrder',
|
||||||
|
disabled,
|
||||||
|
disabledMessage
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const assignmentPriorityOptions = computed(() =>
|
const assignmentPriorityOptions = computed(() =>
|
||||||
@@ -131,7 +159,7 @@ const resetForm = () => {
|
|||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
enabled: false,
|
enabled: true,
|
||||||
assignmentOrder: ROUND_ROBIN,
|
assignmentOrder: ROUND_ROBIN,
|
||||||
conversationPriority: EARLIEST_CREATED,
|
conversationPriority: EARLIEST_CREATED,
|
||||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||||
@@ -162,15 +190,10 @@ defineExpose({
|
|||||||
<BaseInfo
|
<BaseInfo
|
||||||
v-model:policy-name="state.name"
|
v-model:policy-name="state.name"
|
||||||
v-model:description="state.description"
|
v-model:description="state.description"
|
||||||
v-model:enabled="state.enabled"
|
|
||||||
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
|
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
|
||||||
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
|
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
|
||||||
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
|
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
|
||||||
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
|
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
|
||||||
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
|
|
||||||
:status-placeholder="
|
|
||||||
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
|
|
||||||
"
|
|
||||||
@validation-change="handleValidationChange"
|
@validation-change="handleValidationChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -193,6 +216,8 @@ defineExpose({
|
|||||||
:label="option.label"
|
:label="option.label"
|
||||||
:description="option.description"
|
:description="option.description"
|
||||||
:is-active="option.isActive"
|
:is-active="option.isActive"
|
||||||
|
:disabled="option.disabled"
|
||||||
|
:disabled-message="option.disabledMessage"
|
||||||
@select="state[section.key] = $event"
|
@select="state[section.key] = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,6 +276,7 @@ defineExpose({
|
|||||||
:is-fetching="isInboxLoading"
|
:is-fetching="isInboxLoading"
|
||||||
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
|
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
|
||||||
@delete="$emit('deleteInbox', $event)"
|
@delete="$emit('deleteInbox', $event)"
|
||||||
|
@navigate="$emit('navigateToInbox', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
inbox: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isLinking: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['link', 'dismiss']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
const inboxName = computed(() => props.inbox?.name || '');
|
||||||
|
|
||||||
|
const inboxIcon = computed(() => {
|
||||||
|
if (!props.inbox) return 'i-lucide-inbox';
|
||||||
|
return getInboxIconByType(
|
||||||
|
props.inbox.channelType,
|
||||||
|
props.inbox.medium,
|
||||||
|
'line'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
dialogRef.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
dialogRef.value?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('link');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('dismiss');
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.inbox,
|
||||||
|
async newInbox => {
|
||||||
|
if (newInbox) {
|
||||||
|
await nextTick();
|
||||||
|
openDialog();
|
||||||
|
} else {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({ openDialog, closeDialog });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
type="edit"
|
||||||
|
:title="
|
||||||
|
t(
|
||||||
|
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.TITLE'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:confirm-button-label="
|
||||||
|
t(
|
||||||
|
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.LINK_BUTTON'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:cancel-button-label="
|
||||||
|
t(
|
||||||
|
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.CANCEL_BUTTON'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:is-loading="isLinking"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.DESCRIPTION'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-3 rounded-xl border border-n-weak bg-n-alpha-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 size-10 rounded-lg bg-n-alpha-2 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i :class="inboxIcon" class="text-lg text-n-slate-11" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||||
|
{{ inboxName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
+619
-140
@@ -1,122 +1,321 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { minValue } from '@vuelidate/validators';
|
import { minValue } from '@vuelidate/validators';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import SettingsSection from '../../../../../components/SettingsSection.vue';
|
import SettingsSection from '../../../../../components/SettingsSection.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
inbox: {
|
||||||
SettingsSection,
|
type: Object,
|
||||||
NextButton,
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
props: {
|
});
|
||||||
inbox: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const { isEnterprise } = useConfig();
|
|
||||||
|
|
||||||
return { v$: useVuelidate(), isEnterprise };
|
const store = useStore();
|
||||||
},
|
const route = useRoute();
|
||||||
data() {
|
const router = useRouter();
|
||||||
return {
|
const { t } = useI18n();
|
||||||
selectedAgents: [],
|
const { isEnterprise } = useConfig();
|
||||||
isAgentListUpdating: false,
|
|
||||||
enableAutoAssignment: false,
|
const selectedAgents = ref([]);
|
||||||
maxAssignmentLimit: null,
|
const isAgentListUpdating = ref(false);
|
||||||
};
|
const enableAutoAssignment = ref(false);
|
||||||
},
|
const maxAssignmentLimit = ref(null);
|
||||||
computed: {
|
const assignmentPolicy = ref(null);
|
||||||
...mapGetters({
|
const isLoadingPolicy = ref(false);
|
||||||
agentList: 'agents/getAgents',
|
const isDeletingPolicy = ref(false);
|
||||||
}),
|
const showDeleteConfirmModal = ref(false);
|
||||||
maxAssignmentLimitErrors() {
|
const availablePolicies = ref([]);
|
||||||
if (this.v$.maxAssignmentLimit.$error) {
|
const isLoadingPolicies = ref(false);
|
||||||
return this.$t(
|
const showPolicyDropdown = ref(false);
|
||||||
'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
|
const isLinkingPolicy = ref(false);
|
||||||
);
|
|
||||||
}
|
const agentList = computed(() => store.getters['agents/getAgents']);
|
||||||
return '';
|
|
||||||
},
|
const isFeatureEnabled = feature => {
|
||||||
},
|
const accountId = Number(route.params.accountId);
|
||||||
watch: {
|
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||||
inbox() {
|
accountId,
|
||||||
this.setDefaults();
|
feature
|
||||||
},
|
);
|
||||||
},
|
};
|
||||||
mounted() {
|
|
||||||
this.setDefaults();
|
const hasAdvancedAssignment = computed(() => {
|
||||||
},
|
return isFeatureEnabled('advanced_assignment');
|
||||||
methods: {
|
});
|
||||||
setDefaults() {
|
|
||||||
this.enableAutoAssignment = this.inbox.enable_auto_assignment;
|
const hasAssignmentV2 = computed(() => {
|
||||||
this.maxAssignmentLimit =
|
return isFeatureEnabled('assignment_v2');
|
||||||
this.inbox?.auto_assignment_config?.max_assignment_limit || null;
|
});
|
||||||
this.fetchAttachedAgents();
|
|
||||||
},
|
const showAdvancedAssignmentUI = computed(() => {
|
||||||
async fetchAttachedAgents() {
|
return hasAdvancedAssignment.value && hasAssignmentV2.value;
|
||||||
try {
|
});
|
||||||
const response = await this.$store.dispatch('inboxMembers/get', {
|
|
||||||
inboxId: this.inbox.id,
|
const assignmentOrderLabel = computed(() => {
|
||||||
});
|
if (!assignmentPolicy.value) return '';
|
||||||
const {
|
const priority = assignmentPolicy.value.conversation_priority;
|
||||||
data: { payload: inboxMembers },
|
if (priority === 'earliest_created') {
|
||||||
} = response;
|
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
|
||||||
this.selectedAgents = inboxMembers;
|
}
|
||||||
} catch (error) {
|
if (priority === 'longest_waiting') {
|
||||||
// Handle error
|
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
|
||||||
}
|
}
|
||||||
},
|
return priority;
|
||||||
handleEnableAutoAssignment() {
|
});
|
||||||
this.updateInbox();
|
|
||||||
},
|
const assignmentMethodLabel = computed(() => {
|
||||||
async updateAgents() {
|
if (!assignmentPolicy.value) return '';
|
||||||
const agentList = this.selectedAgents.map(el => el.id);
|
const order = assignmentPolicy.value.assignment_order;
|
||||||
this.isAgentListUpdating = true;
|
if (order === 'round_robin') {
|
||||||
try {
|
return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
|
||||||
await this.$store.dispatch('inboxMembers/create', {
|
}
|
||||||
inboxId: this.inbox.id,
|
if (order === 'balanced') {
|
||||||
agentList,
|
return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
|
||||||
});
|
}
|
||||||
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
return order;
|
||||||
} catch (error) {
|
});
|
||||||
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
|
|
||||||
}
|
// Vuelidate validation rules
|
||||||
this.isAgentListUpdating = false;
|
const rules = {
|
||||||
},
|
maxAssignmentLimit: {
|
||||||
async updateInbox() {
|
minValue: minValue(1),
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
id: this.inbox.id,
|
|
||||||
formData: false,
|
|
||||||
enable_auto_assignment: this.enableAutoAssignment,
|
|
||||||
auto_assignment_config: {
|
|
||||||
max_assignment_limit: this.maxAssignmentLimit,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
|
||||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
validations: {
|
|
||||||
selectedAgents: {
|
|
||||||
isEmpty() {
|
|
||||||
return !!this.selectedAgents.length;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
maxAssignmentLimit: {
|
|
||||||
minValue: minValue(1),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(rules, { maxAssignmentLimit });
|
||||||
|
|
||||||
|
const maxAssignmentLimitErrors = computed(() => {
|
||||||
|
if (v$.value.maxAssignmentLimit.$error) {
|
||||||
|
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchAttachedAgents = async () => {
|
||||||
|
try {
|
||||||
|
const response = await store.dispatch('inboxMembers/get', {
|
||||||
|
inboxId: props.inbox.id,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: { payload: inboxMembers },
|
||||||
|
} = response;
|
||||||
|
selectedAgents.value = inboxMembers;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAssignmentPolicy = async () => {
|
||||||
|
if (!props.inbox.id) return;
|
||||||
|
|
||||||
|
isLoadingPolicy.value = true;
|
||||||
|
try {
|
||||||
|
const response = await assignmentPoliciesAPI.getInboxPolicy(props.inbox.id);
|
||||||
|
assignmentPolicy.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// No policy attached, which is fine
|
||||||
|
assignmentPolicy.value = null;
|
||||||
|
} finally {
|
||||||
|
isLoadingPolicy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAvailablePolicies = async () => {
|
||||||
|
isLoadingPolicies.value = true;
|
||||||
|
try {
|
||||||
|
const response = await assignmentPoliciesAPI.get();
|
||||||
|
availablePolicies.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
availablePolicies.value = [];
|
||||||
|
} finally {
|
||||||
|
isLoadingPolicies.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkPolicyToInbox = async policy => {
|
||||||
|
isLinkingPolicy.value = true;
|
||||||
|
try {
|
||||||
|
await assignmentPoliciesAPI.setInboxPolicy(props.inbox.id, policy.id);
|
||||||
|
assignmentPolicy.value = policy;
|
||||||
|
showPolicyDropdown.value = false;
|
||||||
|
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_ERROR'));
|
||||||
|
} finally {
|
||||||
|
isLinkingPolicy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToAssignmentPolicies = () => {
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
router.push({
|
||||||
|
name: 'agent_assignment_policy_index',
|
||||||
|
params: { accountId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const policyMenuItems = computed(() => {
|
||||||
|
const items = availablePolicies.value.map(policy => ({
|
||||||
|
action: 'select_policy',
|
||||||
|
value: policy.id,
|
||||||
|
label: policy.name,
|
||||||
|
icon: 'i-lucide-zap',
|
||||||
|
policy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
action: 'view_all',
|
||||||
|
value: 'view_all',
|
||||||
|
label: t('INBOX_MGMT.ASSIGNMENT.VIEW_ALL_POLICIES'),
|
||||||
|
icon: 'i-lucide-arrow-right',
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePolicyMenuAction = ({ action, policy }) => {
|
||||||
|
if (action === 'select_policy' && policy) {
|
||||||
|
linkPolicyToInbox(policy);
|
||||||
|
} else if (action === 'view_all') {
|
||||||
|
navigateToAssignmentPolicies();
|
||||||
|
}
|
||||||
|
showPolicyDropdown.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePolicyDropdown = () => {
|
||||||
|
if (!showPolicyDropdown.value && availablePolicies.value.length === 0) {
|
||||||
|
fetchAvailablePolicies();
|
||||||
|
}
|
||||||
|
showPolicyDropdown.value = !showPolicyDropdown.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePolicyDropdown = () => {
|
||||||
|
showPolicyDropdown.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAutoAssignment = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: props.inbox.id,
|
||||||
|
formData: false,
|
||||||
|
enable_auto_assignment: enableAutoAssignment.value,
|
||||||
|
};
|
||||||
|
await store.dispatch('inboxes/updateInbox', payload);
|
||||||
|
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAgents = async () => {
|
||||||
|
const agentListIds = selectedAgents.value.map(el => el.id);
|
||||||
|
isAgentListUpdating.value = true;
|
||||||
|
try {
|
||||||
|
await store.dispatch('inboxMembers/create', {
|
||||||
|
inboxId: props.inbox.id,
|
||||||
|
agentList: agentListIds,
|
||||||
|
});
|
||||||
|
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||||
|
}
|
||||||
|
isAgentListUpdating.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInbox = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: props.inbox.id,
|
||||||
|
formData: false,
|
||||||
|
enable_auto_assignment: enableAutoAssignment.value,
|
||||||
|
auto_assignment_config: {
|
||||||
|
max_assignment_limit: maxAssignmentLimit.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await store.dispatch('inboxes/updateInbox', payload);
|
||||||
|
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToCreatePolicy = () => {
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
router.push({
|
||||||
|
name: 'agent_assignment_policy_create',
|
||||||
|
params: { accountId },
|
||||||
|
query: { inboxId: props.inbox.id },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToAssignmentPolicyEdit = () => {
|
||||||
|
if (!assignmentPolicy.value?.id) return;
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
router.push({
|
||||||
|
name: 'agent_assignment_policy_edit',
|
||||||
|
params: { accountId, id: assignmentPolicy.value.id },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToBilling = () => {
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
router.push({
|
||||||
|
name: 'billing_settings_index',
|
||||||
|
params: { accountId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeletePolicy = () => {
|
||||||
|
showDeleteConfirmModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDeletePolicy = () => {
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAssignmentPolicy = async () => {
|
||||||
|
if (isDeletingPolicy.value) return;
|
||||||
|
isDeletingPolicy.value = true;
|
||||||
|
try {
|
||||||
|
await assignmentPoliciesAPI.removeInboxPolicy(props.inbox.id);
|
||||||
|
assignmentPolicy.value = null;
|
||||||
|
showDeleteConfirmModal.value = false;
|
||||||
|
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_ERROR'));
|
||||||
|
} finally {
|
||||||
|
isDeletingPolicy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDefaults = () => {
|
||||||
|
enableAutoAssignment.value = props.inbox.enable_auto_assignment;
|
||||||
|
maxAssignmentLimit.value =
|
||||||
|
props.inbox.auto_assignment_config?.max_assignment_limit || null;
|
||||||
|
fetchAttachedAgents();
|
||||||
|
if (showAdvancedAssignmentUI.value) {
|
||||||
|
fetchAssignmentPolicy();
|
||||||
|
fetchAvailablePolicies();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch only inbox.id to avoid unnecessary refetches when other properties change
|
||||||
|
watch(() => props.inbox.id, setDefaults);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setDefaults();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -138,7 +337,6 @@ export default {
|
|||||||
selected-label
|
selected-label
|
||||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||||
@select="v$.selectedAgents.$touch"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NextButton
|
<NextButton
|
||||||
@@ -152,44 +350,325 @@ export default {
|
|||||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
|
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
|
||||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
|
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
|
||||||
>
|
>
|
||||||
<label class="w-3/4 settings-item">
|
<!-- New UI for assignment_v2 -->
|
||||||
<div class="flex items-center gap-2">
|
<template v-if="hasAssignmentV2">
|
||||||
<input
|
<div class="flex items-start gap-3">
|
||||||
id="enableAutoAssignment"
|
<Switch
|
||||||
v-model="enableAutoAssignment"
|
v-model="enableAutoAssignment"
|
||||||
type="checkbox"
|
class="flex-shrink-0 mt-0.5"
|
||||||
@change="handleEnableAutoAssignment"
|
@change="handleToggleAutoAssignment"
|
||||||
/>
|
/>
|
||||||
<label for="enableAutoAssignment">
|
<div class="flex-grow">
|
||||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
|
<label class="text-sm text-n-slate-12 font-medium mb-1">
|
||||||
</label>
|
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
|
||||||
|
</label>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
<Transition
|
||||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
</p>
|
enter-from-class="opacity-0 -translate-y-2"
|
||||||
</label>
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 -translate-y-2"
|
||||||
|
>
|
||||||
|
<div v-if="enableAutoAssignment" class="mt-6">
|
||||||
|
<!-- Policy Card - When policy is attached -->
|
||||||
|
<div
|
||||||
|
v-if="showAdvancedAssignmentUI && assignmentPolicy"
|
||||||
|
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="i-lucide-zap text-xl text-n-slate-11" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<span class="text-base font-medium text-n-slate-12 mb-1">
|
||||||
|
{{ assignmentPolicy.name }}
|
||||||
|
</span>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NextButton
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
ghost
|
||||||
|
ruby
|
||||||
|
sm
|
||||||
|
@click="confirmDeletePolicy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
|
<ul class="space-y-2 mb-6">
|
||||||
<woot-input
|
<li class="flex items-center gap-2">
|
||||||
v-model="maxAssignmentLimit"
|
<span
|
||||||
type="number"
|
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||||
:class="{ error: v$.maxAssignmentLimit.$error }"
|
/>
|
||||||
:error="maxAssignmentLimitErrors"
|
<span class="text-sm text-n-slate-12">
|
||||||
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
|
{{ assignmentOrderLabel }}
|
||||||
@blur="v$.maxAssignmentLimit.$touch"
|
</span>
|
||||||
/>
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-12">
|
||||||
|
{{ assignmentMethodLabel }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
<div class="w-full h-px my-4 bg-n-weak" />
|
||||||
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<NextButton
|
<NextButton
|
||||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
:label="$t('INBOX_MGMT.ASSIGNMENT.CUSTOMIZE_POLICY')"
|
||||||
:disabled="v$.maxAssignmentLimit.$invalid"
|
icon="i-lucide-arrow-right"
|
||||||
@click="updateInbox"
|
trailing-icon
|
||||||
/>
|
link
|
||||||
</div>
|
class="mb-2"
|
||||||
|
@click="navigateToAssignmentPolicyEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default Policy - When no custom policy attached but feature enabled -->
|
||||||
|
<div
|
||||||
|
v-else-if="
|
||||||
|
showAdvancedAssignmentUI &&
|
||||||
|
!assignmentPolicy &&
|
||||||
|
!isLoadingPolicy
|
||||||
|
"
|
||||||
|
class="rounded-xl outline-1 outline-n-weak outline"
|
||||||
|
>
|
||||||
|
<!-- Default Policy Header -->
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-zap text-xl text-n-slate-11" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h4 class="text-base font-medium text-n-slate-12 mb-1">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{
|
||||||
|
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="mt-5 flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
v-if="!isLoadingPolicies && availablePolicies.length > 0"
|
||||||
|
v-on-click-outside="closePolicyDropdown"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
|
||||||
|
@click="togglePolicyDropdown"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-link text-sm" />
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
|
||||||
|
<i
|
||||||
|
class="i-lucide-chevron-down text-sm transition-transform"
|
||||||
|
:class="{ 'rotate-180': showPolicyDropdown }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showPolicyDropdown"
|
||||||
|
class="top-full left-0 mt-2 min-w-72"
|
||||||
|
:menu-items="policyMenuItems"
|
||||||
|
:is-searching="isLoadingPolicies"
|
||||||
|
@action="handlePolicyMenuAction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
|
||||||
|
@click="navigateToCreatePolicy"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-plus text-sm" />
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default Rules Info -->
|
||||||
|
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-n-slate-11 mb-2">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
|
||||||
|
<div
|
||||||
|
v-else-if="!showAdvancedAssignmentUI"
|
||||||
|
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-zap text-xl text-n-slate-11" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h4 class="text-base font-medium text-n-slate-12 mb-1">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-n-slate-11 mb-4">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="space-y-2 mb-6">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-n-weak my-4" />
|
||||||
|
|
||||||
|
<!-- Upgrade prompt when advanced_assignment is not enabled -->
|
||||||
|
<div v-if="!hasAdvancedAssignment">
|
||||||
|
<p class="text-sm text-n-slate-11 mb-1">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
|
||||||
|
</p>
|
||||||
|
<NextButton
|
||||||
|
:label="$t('INBOX_MGMT.ASSIGNMENT.UPGRADE_TO_BUSINESS')"
|
||||||
|
icon="i-lucide-arrow-right"
|
||||||
|
trailing-icon
|
||||||
|
link
|
||||||
|
@click="navigateToBilling"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Old UI for non-assignment_v2 -->
|
||||||
|
<template v-else>
|
||||||
|
<label class="w-3/4 settings-item">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="enableAutoAssignment"
|
||||||
|
v-model="enableAutoAssignment"
|
||||||
|
type="checkbox"
|
||||||
|
@change="handleToggleAutoAssignment"
|
||||||
|
/>
|
||||||
|
<label for="enableAutoAssignment">
|
||||||
|
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||||
|
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
|
||||||
|
<woot-input
|
||||||
|
v-model="maxAssignmentLimit"
|
||||||
|
type="number"
|
||||||
|
:class="{ error: v$.maxAssignmentLimit.$error }"
|
||||||
|
:error="maxAssignmentLimitErrors"
|
||||||
|
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
|
||||||
|
@blur="v$.maxAssignmentLimit.$touch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||||
|
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<NextButton
|
||||||
|
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||||
|
:disabled="v$.maxAssignmentLimit.$invalid"
|
||||||
|
@click="updateInbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<woot-modal
|
||||||
|
v-if="showDeleteConfirmModal"
|
||||||
|
:show="showDeleteConfirmModal"
|
||||||
|
:on-close="cancelDeletePolicy"
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_TITLE') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-n-slate-11 mb-6 ml-13">
|
||||||
|
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_MESSAGE') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NextButton
|
||||||
|
color="slate"
|
||||||
|
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')"
|
||||||
|
@click="cancelDeletePolicy"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
color="ruby"
|
||||||
|
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')"
|
||||||
|
:is-loading="isDeletingPolicy"
|
||||||
|
@click="deleteAssignmentPolicy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</woot-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const actions = {
|
|||||||
data: payload,
|
data: payload,
|
||||||
});
|
});
|
||||||
if (!payload.length) {
|
if (!payload.length) {
|
||||||
commit(types.SET_ALL_MESSAGES_LOADED);
|
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle error
|
// Handle error
|
||||||
@@ -191,7 +191,7 @@ const actions = {
|
|||||||
|
|
||||||
async setActiveChat({ commit, dispatch }, { data, after }) {
|
async setActiveChat({ commit, dispatch }, { data, after }) {
|
||||||
commit(types.SET_CURRENT_CHAT_WINDOW, data);
|
commit(types.SET_CURRENT_CHAT_WINDOW, data);
|
||||||
commit(types.CLEAR_ALL_MESSAGES_LOADED);
|
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id);
|
||||||
if (data.dataFetched === undefined) {
|
if (data.dataFetched === undefined) {
|
||||||
try {
|
try {
|
||||||
await dispatch('fetchPreviousMessages', {
|
await dispatch('fetchPreviousMessages', {
|
||||||
@@ -199,7 +199,7 @@ const actions = {
|
|||||||
before: data.messages[0].id,
|
before: data.messages[0].id,
|
||||||
conversationId: data.id,
|
conversationId: data.id,
|
||||||
});
|
});
|
||||||
data.dataFetched = true;
|
commit(types.SET_CHAT_DATA_FETCHED, data.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
||||||
@@ -212,14 +212,17 @@ const actions = {
|
|||||||
conversationId,
|
conversationId,
|
||||||
agentId,
|
agentId,
|
||||||
});
|
});
|
||||||
dispatch('setCurrentChatAssignee', response.data);
|
dispatch('setCurrentChatAssignee', {
|
||||||
|
conversationId,
|
||||||
|
assignee: response.data,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle error
|
// Handle error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setCurrentChatAssignee({ commit }, assignee) {
|
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
|
||||||
commit(types.ASSIGN_AGENT, assignee);
|
commit(types.ASSIGN_AGENT, { conversationId, assignee });
|
||||||
},
|
},
|
||||||
|
|
||||||
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
||||||
|
|||||||
@@ -63,14 +63,18 @@ export const mutations = {
|
|||||||
_state.allConversations = [];
|
_state.allConversations = [];
|
||||||
_state.selectedChatId = null;
|
_state.selectedChatId = null;
|
||||||
},
|
},
|
||||||
[types.SET_ALL_MESSAGES_LOADED](_state) {
|
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) {
|
||||||
const [chat] = getSelectedChatConversation(_state);
|
const chat = getConversationById(_state)(conversationId);
|
||||||
chat.allMessagesLoaded = true;
|
if (chat) {
|
||||||
|
chat.allMessagesLoaded = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.CLEAR_ALL_MESSAGES_LOADED](_state) {
|
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) {
|
||||||
const [chat] = getSelectedChatConversation(_state);
|
const chat = getConversationById(_state)(conversationId);
|
||||||
chat.allMessagesLoaded = false;
|
if (chat) {
|
||||||
|
chat.allMessagesLoaded = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
|
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
|
||||||
_state.selectedChatId = null;
|
_state.selectedChatId = null;
|
||||||
@@ -91,15 +95,24 @@ export const mutations = {
|
|||||||
chat.messages = data;
|
chat.messages = data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[types.SET_CHAT_DATA_FETCHED](_state, conversationId) {
|
||||||
|
const chat = getConversationById(_state)(conversationId);
|
||||||
|
if (chat) {
|
||||||
|
chat.dataFetched = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
|
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
|
||||||
if (activeChat) {
|
if (activeChat) {
|
||||||
_state.selectedChatId = activeChat.id;
|
_state.selectedChatId = activeChat.id;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.ASSIGN_AGENT](_state, assignee) {
|
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
|
||||||
const [chat] = getSelectedChatConversation(_state);
|
const chat = getConversationById(_state)(conversationId);
|
||||||
chat.meta.assignee = assignee;
|
if (chat) {
|
||||||
|
chat.meta.assignee = assignee;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
|
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
|
||||||
@@ -274,8 +287,10 @@ export const mutations = {
|
|||||||
|
|
||||||
// Update assignee on action cable message
|
// Update assignee on action cable message
|
||||||
[types.UPDATE_ASSIGNEE](_state, payload) {
|
[types.UPDATE_ASSIGNEE](_state, payload) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === payload.id);
|
const chat = getConversationById(_state)(payload.id);
|
||||||
chat.meta.assignee = payload.assignee;
|
if (chat) {
|
||||||
|
chat.meta.assignee = payload.assignee;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
|
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ export const actions = {
|
|||||||
try {
|
try {
|
||||||
await InboxesAPI.delete(inboxId);
|
await InboxesAPI.delete(inboxId);
|
||||||
commit(types.default.DELETE_INBOXES, inboxId);
|
commit(types.default.DELETE_INBOXES, inboxId);
|
||||||
|
// Clear IndexedDB so stale inbox data isn't served on page refresh
|
||||||
|
await InboxesAPI.clearDataInCache();
|
||||||
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
|
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
|
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
|
||||||
|
|||||||
@@ -355,22 +355,26 @@ describe('#actions', () => {
|
|||||||
axios.post.mockResolvedValue({
|
axios.post.mockResolvedValue({
|
||||||
data: { id: 1, name: 'User' },
|
data: { id: 1, name: 'User' },
|
||||||
});
|
});
|
||||||
await actions.assignAgent({ commit }, { conversationId: 1, agentId: 1 });
|
await actions.assignAgent(
|
||||||
expect(commit).toHaveBeenCalledTimes(0);
|
{ dispatch },
|
||||||
expect(commit.mock.calls).toEqual([]);
|
{ conversationId: 1, agentId: 1 }
|
||||||
|
);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
|
||||||
|
conversationId: 1,
|
||||||
|
assignee: { id: 1, name: 'User' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#setCurrentChatAssignee', () => {
|
describe('#setCurrentChatAssignee', () => {
|
||||||
it('sends correct mutations if assignment is successful', async () => {
|
it('sends correct mutations if assignment is successful', async () => {
|
||||||
axios.post.mockResolvedValue({
|
const payload = {
|
||||||
data: { id: 1, name: 'User' },
|
conversationId: 1,
|
||||||
});
|
assignee: { id: 1, name: 'User' },
|
||||||
await actions.setCurrentChatAssignee({ commit }, { id: 1, name: 'User' });
|
};
|
||||||
|
await actions.setCurrentChatAssignee({ commit }, payload);
|
||||||
expect(commit).toHaveBeenCalledTimes(1);
|
expect(commit).toHaveBeenCalledTimes(1);
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([['ASSIGN_AGENT', payload]]);
|
||||||
['ASSIGN_AGENT', { id: 1, name: 'User' }],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -716,6 +720,64 @@ describe('#addMentions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#setActiveChat', () => {
|
||||||
|
it('should commit SET_CHAT_DATA_FETCHED with conversation ID after fetch', async () => {
|
||||||
|
const localCommit = vi.fn();
|
||||||
|
const localDispatch = vi.fn().mockResolvedValue();
|
||||||
|
const data = { id: 42, messages: [{ id: 100 }] };
|
||||||
|
|
||||||
|
await actions.setActiveChat(
|
||||||
|
{ commit: localCommit, dispatch: localDispatch },
|
||||||
|
{ data, after: 99 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(localCommit.mock.calls).toEqual([
|
||||||
|
[types.SET_CURRENT_CHAT_WINDOW, data],
|
||||||
|
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
|
||||||
|
[types.SET_CHAT_DATA_FETCHED, 42],
|
||||||
|
]);
|
||||||
|
expect(localDispatch).toHaveBeenCalledWith('fetchPreviousMessages', {
|
||||||
|
after: 99,
|
||||||
|
before: 100,
|
||||||
|
conversationId: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch fetchPreviousMessages if dataFetched is already set', async () => {
|
||||||
|
const localCommit = vi.fn();
|
||||||
|
const localDispatch = vi.fn();
|
||||||
|
const data = { id: 42, messages: [{ id: 100 }], dataFetched: true };
|
||||||
|
|
||||||
|
await actions.setActiveChat(
|
||||||
|
{ commit: localCommit, dispatch: localDispatch },
|
||||||
|
{ data }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(localCommit.mock.calls).toEqual([
|
||||||
|
[types.SET_CURRENT_CHAT_WINDOW, data],
|
||||||
|
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
|
||||||
|
]);
|
||||||
|
expect(localDispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => {
|
||||||
|
const localCommit = vi.fn();
|
||||||
|
const localDispatch = vi.fn().mockResolvedValue();
|
||||||
|
const data = { id: 42, messages: [{ id: 100 }] };
|
||||||
|
|
||||||
|
await actions.setActiveChat(
|
||||||
|
{ commit: localCommit, dispatch: localDispatch },
|
||||||
|
{ data }
|
||||||
|
);
|
||||||
|
|
||||||
|
// The action must NOT set dataFetched on the data object directly
|
||||||
|
expect(data.dataFetched).toBeUndefined();
|
||||||
|
|
||||||
|
// Instead it commits a mutation that finds the conversation by ID in the store
|
||||||
|
expect(localCommit).toHaveBeenCalledWith(types.SET_CHAT_DATA_FETCHED, 42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#getInboxCaptainAssistantById', () => {
|
describe('#getInboxCaptainAssistantById', () => {
|
||||||
it('fetches inbox assistant by id', async () => {
|
it('fetches inbox assistant by id', async () => {
|
||||||
axios.get.mockResolvedValue({
|
axios.get.mockResolvedValue({
|
||||||
|
|||||||
@@ -570,25 +570,84 @@ describe('#mutations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#SET_ALL_MESSAGES_LOADED', () => {
|
describe('#SET_CHAT_DATA_FETCHED', () => {
|
||||||
it('should set allMessagesLoaded to true on selected chat', () => {
|
it('should set dataFetched to true on the conversation by ID', () => {
|
||||||
const state = {
|
const state = {
|
||||||
allConversations: [{ id: 1, allMessagesLoaded: false }],
|
allConversations: [{ id: 1 }, { id: 2 }],
|
||||||
|
};
|
||||||
|
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
|
||||||
|
expect(state.allConversations[0].dataFetched).toBe(true);
|
||||||
|
expect(state.allConversations[1].dataFetched).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if conversation is not found', () => {
|
||||||
|
const state = { allConversations: [{ id: 1 }] };
|
||||||
|
mutations[types.SET_CHAT_DATA_FETCHED](state, 999);
|
||||||
|
expect(state.allConversations[0].dataFetched).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should survive the race: SET_ALL_CONVERSATION replaces the object, then SET_CHAT_DATA_FETCHED still works', () => {
|
||||||
|
// 1. Initial state: conversation exists with dataFetched undefined
|
||||||
|
const state = {
|
||||||
|
allConversations: [{ id: 1, messages: [{ id: 'm1' }] }],
|
||||||
selectedChatId: 1,
|
selectedChatId: 1,
|
||||||
};
|
};
|
||||||
mutations[types.SET_ALL_MESSAGES_LOADED](state);
|
const originalRef = state.allConversations[0];
|
||||||
|
|
||||||
|
// 2. Simulate SET_ALL_CONVERSATION replacing the object (WebSocket/polling)
|
||||||
|
// This copies dataFetched from the old object (still undefined)
|
||||||
|
mutations[types.SET_ALL_CONVERSATION](state, [
|
||||||
|
{ id: 1, name: 'refreshed', messages: [{ id: 'm2' }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The store now holds a NEW object, old reference is detached
|
||||||
|
const newRef = state.allConversations[0];
|
||||||
|
expect(newRef).not.toBe(originalRef);
|
||||||
|
expect(newRef.dataFetched).toBeUndefined();
|
||||||
|
|
||||||
|
// 3. SET_CHAT_DATA_FETCHED finds by ID — works on the current store object
|
||||||
|
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
|
||||||
|
expect(state.allConversations[0].dataFetched).toBe(true);
|
||||||
|
|
||||||
|
// Old detached reference is unaffected
|
||||||
|
expect(originalRef.dataFetched).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#SET_ALL_MESSAGES_LOADED', () => {
|
||||||
|
it('should set allMessagesLoaded to true on the conversation by ID', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [{ id: 1, allMessagesLoaded: false }, { id: 2 }],
|
||||||
|
};
|
||||||
|
mutations[types.SET_ALL_MESSAGES_LOADED](state, 1);
|
||||||
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
|
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
|
||||||
|
expect(state.allConversations[1].allMessagesLoaded).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if conversation is not found', () => {
|
||||||
|
const state = { allConversations: [{ id: 1 }] };
|
||||||
|
mutations[types.SET_ALL_MESSAGES_LOADED](state, 999);
|
||||||
|
expect(state.allConversations[0].allMessagesLoaded).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
|
describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
|
||||||
it('should set allMessagesLoaded to false on selected chat', () => {
|
it('should set allMessagesLoaded to false on the conversation by ID', () => {
|
||||||
const state = {
|
const state = {
|
||||||
allConversations: [{ id: 1, allMessagesLoaded: true }],
|
allConversations: [
|
||||||
selectedChatId: 1,
|
{ id: 1, allMessagesLoaded: true },
|
||||||
|
{ id: 2, allMessagesLoaded: true },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
|
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 1);
|
||||||
expect(state.allConversations[0].allMessagesLoaded).toBe(false);
|
expect(state.allConversations[0].allMessagesLoaded).toBe(false);
|
||||||
|
expect(state.allConversations[1].allMessagesLoaded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if conversation is not found', () => {
|
||||||
|
const state = { allConversations: [{ id: 1, allMessagesLoaded: true }] };
|
||||||
|
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 999);
|
||||||
|
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -640,15 +699,22 @@ describe('#mutations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('#ASSIGN_AGENT', () => {
|
describe('#ASSIGN_AGENT', () => {
|
||||||
it('should assign agent to selected conversation', () => {
|
it('should assign agent to the correct conversation by ID', () => {
|
||||||
const assignee = { id: 1, name: 'Agent' };
|
const assignee = { id: 1, name: 'Agent' };
|
||||||
const state = {
|
const state = {
|
||||||
allConversations: [{ id: 1, meta: {} }],
|
allConversations: [
|
||||||
selectedChatId: 1,
|
{ id: 1, meta: {} },
|
||||||
|
{ id: 2, meta: {} },
|
||||||
|
],
|
||||||
|
selectedChatId: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
mutations[types.ASSIGN_AGENT](state, assignee);
|
mutations[types.ASSIGN_AGENT](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
assignee,
|
||||||
|
});
|
||||||
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
|
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
|
||||||
|
expect(state.allConversations[1].meta.assignee).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -797,6 +863,34 @@ describe('#mutations', () => {
|
|||||||
mutations[types.UPDATE_CONVERSATION](state, conversation);
|
mutations[types.UPDATE_CONVERSATION](state, conversation);
|
||||||
expect(state.allConversations[0].status).toEqual('resolved');
|
expect(state.allConversations[0].status).toEqual('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve dataFetched and allMessagesLoaded during update', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
status: 'open',
|
||||||
|
updated_at: 100,
|
||||||
|
messages: [{ id: 'msg1' }],
|
||||||
|
dataFetched: true,
|
||||||
|
allMessagesLoaded: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversation = {
|
||||||
|
id: 1,
|
||||||
|
status: 'resolved',
|
||||||
|
updated_at: 200,
|
||||||
|
messages: [{ id: 'msg2' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
mutations[types.UPDATE_CONVERSATION](state, conversation);
|
||||||
|
expect(state.allConversations[0].status).toEqual('resolved');
|
||||||
|
expect(state.allConversations[0].dataFetched).toBe(true);
|
||||||
|
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
|
||||||
|
expect(state.allConversations[0].messages).toEqual([{ id: 'msg1' }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#UPDATE_CONVERSATION_CONTACT', () => {
|
describe('#UPDATE_CONVERSATION_CONTACT', () => {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default {
|
|||||||
|
|
||||||
SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID',
|
SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID',
|
||||||
|
|
||||||
|
SET_CHAT_DATA_FETCHED: 'SET_CHAT_DATA_FETCHED',
|
||||||
SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS',
|
SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS',
|
||||||
UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS',
|
UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS',
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
|||||||
content_type: avatar_file.content_type
|
content_type: avatar_file.content_type
|
||||||
)
|
)
|
||||||
|
|
||||||
rescue Down::NotFound, Down::Error => e
|
rescue Down::NotFound
|
||||||
|
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
|
||||||
|
rescue Down::Error => e
|
||||||
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
|
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
|
||||||
ensure
|
ensure
|
||||||
update_avatar_sync_attributes(avatarable, avatar_url)
|
update_avatar_sync_attributes(avatarable, avatar_url)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class Account < ApplicationRecord
|
|||||||
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
||||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||||
'auto_resolve_label': { 'type': %w[string null] },
|
'auto_resolve_label': { 'type': %w[string null] },
|
||||||
|
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||||
'conversation_required_attributes': {
|
'conversation_required_attributes': {
|
||||||
'type': %w[array null],
|
'type': %w[array null],
|
||||||
'items': { 'type': 'string' }
|
'items': { 'type': 'string' }
|
||||||
@@ -88,6 +89,7 @@ class Account < ApplicationRecord
|
|||||||
|
|
||||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||||
store_accessor :settings, :captain_models, :captain_features
|
store_accessor :settings, :captain_models, :captain_features
|
||||||
|
store_accessor :settings, :keep_pending_on_bot_failure
|
||||||
|
|
||||||
has_many :account_users, dependent: :destroy_async
|
has_many :account_users, dependent: :destroy_async
|
||||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ module AccountEmailRateLimitable
|
|||||||
Redis::Alfred.get(email_count_cache_key).to_i
|
Redis::Alfred.get(email_count_cache_key).to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def email_transcript_enabled?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
def within_email_rate_limit?
|
def within_email_rate_limit?
|
||||||
return true if emails_sent_today < email_rate_limit
|
return true if emails_sent_today < email_rate_limit
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,18 @@ module AutoAssignmentHandler
|
|||||||
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||||
else
|
else
|
||||||
# Use legacy assignment system
|
# Use legacy assignment system
|
||||||
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
|
# If conversation has a team, only consider team members for assignment
|
||||||
|
allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity
|
||||||
|
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def team_member_ids_with_capacity
|
||||||
|
return [] if team.blank? || team.allow_auto_assign.blank?
|
||||||
|
|
||||||
|
inbox.member_ids_with_assignment_capacity & team.members.ids
|
||||||
|
end
|
||||||
|
|
||||||
def should_run_auto_assignment?
|
def should_run_auto_assignment?
|
||||||
return false unless inbox.enable_auto_assignment?
|
return false unless inbox.enable_auto_assignment?
|
||||||
|
|
||||||
|
|||||||
@@ -130,11 +130,15 @@ class MailPresenter < SimpleDelegator
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sender_name
|
def sender_name
|
||||||
Mail::Address.new((@mail[:reply_to] || @mail[:from]).value).name
|
parse_mail_address((@mail[:reply_to] || @mail[:from]).value)&.name
|
||||||
end
|
end
|
||||||
|
|
||||||
def original_sender
|
def original_sender
|
||||||
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
|
[
|
||||||
|
@mail[:reply_to]&.value,
|
||||||
|
@mail['X-Original-Sender']&.value,
|
||||||
|
@mail[:from]&.value
|
||||||
|
].filter_map { |email| parse_mail_address(email)&.address }.first
|
||||||
end
|
end
|
||||||
|
|
||||||
def headers_data
|
def headers_data
|
||||||
@@ -147,10 +151,6 @@ class MailPresenter < SimpleDelegator
|
|||||||
headers.presence
|
headers.presence
|
||||||
end
|
end
|
||||||
|
|
||||||
def from_email_address(email)
|
|
||||||
Mail::Address.new(email).address
|
|
||||||
end
|
|
||||||
|
|
||||||
def email_forwarded_for
|
def email_forwarded_for
|
||||||
@mail['X-Forwarded-For'].try(:value)
|
@mail['X-Forwarded-For'].try(:value)
|
||||||
end
|
end
|
||||||
@@ -175,11 +175,20 @@ class MailPresenter < SimpleDelegator
|
|||||||
|
|
||||||
def notification_email_from_chatwoot?
|
def notification_email_from_chatwoot?
|
||||||
# notification emails are send via mailer sender email address. so it should match
|
# notification emails are send via mailer sender email address. so it should match
|
||||||
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
|
configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
|
||||||
|
original_sender.to_s.casecmp?(configured_sender)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def parse_mail_address(email)
|
||||||
|
return if email.blank?
|
||||||
|
|
||||||
|
Mail::Address.new(email)
|
||||||
|
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def auto_submitted?
|
def auto_submitted?
|
||||||
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
|
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class ActionService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_email_transcript(emails)
|
def send_email_transcript(emails)
|
||||||
|
return unless @account.email_transcript_enabled?
|
||||||
|
|
||||||
emails = emails[0].gsub(/\s+/, '').split(',')
|
emails = emails[0].gsub(/\s+/, '').split(',')
|
||||||
|
|
||||||
emails.each do |email|
|
emails.each do |email|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ class AutoAssignment::AssignmentService
|
|||||||
|
|
||||||
def perform_bulk_assignment(limit: 100)
|
def perform_bulk_assignment(limit: 100)
|
||||||
return 0 unless inbox.auto_assignment_v2_enabled?
|
return 0 unless inbox.auto_assignment_v2_enabled?
|
||||||
|
return 0 unless inbox.enable_auto_assignment?
|
||||||
|
|
||||||
assigned_count = 0
|
assigned_count = 0
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class AutoAssignment::AssignmentService
|
|||||||
def perform_for_conversation(conversation)
|
def perform_for_conversation(conversation)
|
||||||
return false unless assignable?(conversation)
|
return false unless assignable?(conversation)
|
||||||
|
|
||||||
agent = find_available_agent
|
agent = find_available_agent(conversation)
|
||||||
return false unless agent
|
return false unless agent
|
||||||
|
|
||||||
assign_conversation(conversation, agent)
|
assign_conversation(conversation, agent)
|
||||||
@@ -32,7 +33,9 @@ class AutoAssignment::AssignmentService
|
|||||||
def unassigned_conversations(limit)
|
def unassigned_conversations(limit)
|
||||||
scope = inbox.conversations.unassigned.open
|
scope = inbox.conversations.unassigned.open
|
||||||
|
|
||||||
scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
|
# Apply conversation priority using assignment policy if available
|
||||||
|
policy = inbox.assignment_policy
|
||||||
|
scope = if policy&.longest_waiting?
|
||||||
scope.reorder(last_activity_at: :asc, created_at: :asc)
|
scope.reorder(last_activity_at: :asc, created_at: :asc)
|
||||||
else
|
else
|
||||||
scope.reorder(created_at: :asc)
|
scope.reorder(created_at: :asc)
|
||||||
@@ -41,13 +44,26 @@ class AutoAssignment::AssignmentService
|
|||||||
scope.limit(limit)
|
scope.limit(limit)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_available_agent
|
def find_available_agent(conversation = nil)
|
||||||
agents = filter_agents_by_rate_limit(inbox.available_agents)
|
agents = filter_agents_by_team(inbox.available_agents, conversation)
|
||||||
|
return nil if agents.nil?
|
||||||
|
|
||||||
|
agents = filter_agents_by_rate_limit(agents)
|
||||||
return nil if agents.empty?
|
return nil if agents.empty?
|
||||||
|
|
||||||
round_robin_selector.select_agent(agents)
|
round_robin_selector.select_agent(agents)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_agents_by_team(agents, conversation)
|
||||||
|
return agents if conversation&.team_id.blank?
|
||||||
|
|
||||||
|
team = conversation.team
|
||||||
|
return nil if team.blank? || team.allow_auto_assign.blank?
|
||||||
|
|
||||||
|
team_member_ids = team.members.ids
|
||||||
|
agents.where(user_id: team_member_ids)
|
||||||
|
end
|
||||||
|
|
||||||
def filter_agents_by_rate_limit(agents)
|
def filter_agents_by_rate_limit(agents)
|
||||||
agents.select do |agent_member|
|
agents.select do |agent_member|
|
||||||
rate_limiter = build_rate_limiter(agent_member.user)
|
rate_limiter = build_rate_limiter(agent_member.user)
|
||||||
@@ -81,10 +97,6 @@ class AutoAssignment::AssignmentService
|
|||||||
def round_robin_selector
|
def round_robin_selector
|
||||||
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
|
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
|
||||||
end
|
end
|
||||||
|
|
||||||
def assignment_config
|
|
||||||
@assignment_config ||= inbox.auto_assignment_config || {}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
|
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ class AutoAssignment::RateLimiter
|
|||||||
end
|
end
|
||||||
|
|
||||||
def track_assignment(conversation)
|
def track_assignment(conversation)
|
||||||
return unless enabled?
|
|
||||||
|
|
||||||
assignment_key = build_assignment_key(conversation.id)
|
assignment_key = build_assignment_key(conversation.id)
|
||||||
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
|
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
|
||||||
end
|
end
|
||||||
@@ -24,11 +22,11 @@ class AutoAssignment::RateLimiter
|
|||||||
private
|
private
|
||||||
|
|
||||||
def enabled?
|
def enabled?
|
||||||
limit.present? && limit.positive?
|
config.present? && limit.positive?
|
||||||
end
|
end
|
||||||
|
|
||||||
def limit
|
def limit
|
||||||
config&.fair_distribution_limit&.to_i || Math
|
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
|
||||||
end
|
end
|
||||||
|
|
||||||
def window
|
def window
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ class MessageTemplates::HookExecutionService
|
|||||||
pattr_initialize [:message!]
|
pattr_initialize [:message!]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
return if conversation.campaign.present?
|
|
||||||
return if conversation.last_incoming_message.blank?
|
return if conversation.last_incoming_message.blank?
|
||||||
return if message.auto_reply_email?
|
return if message.auto_reply_email?
|
||||||
|
|
||||||
@@ -21,6 +20,7 @@ class MessageTemplates::HookExecutionService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def should_send_out_of_office_message?
|
def should_send_out_of_office_message?
|
||||||
|
return false if conversation.campaign.present?
|
||||||
# should not send if its a tweet message
|
# should not send if its a tweet message
|
||||||
return false if conversation.tweet?
|
return false if conversation.tweet?
|
||||||
# should not send for outbound messages
|
# should not send for outbound messages
|
||||||
@@ -37,6 +37,7 @@ class MessageTemplates::HookExecutionService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def should_send_greeting?
|
def should_send_greeting?
|
||||||
|
return false if conversation.campaign.present?
|
||||||
# should not send if its a tweet message
|
# should not send if its a tweet message
|
||||||
return false if conversation.tweet?
|
return false if conversation.tweet?
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ class MessageTemplates::HookExecutionService
|
|||||||
|
|
||||||
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages
|
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages
|
||||||
def should_send_email_collect?
|
def should_send_email_collect?
|
||||||
|
return false if conversation.campaign.present?
|
||||||
|
|
||||||
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
|
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
require 'faraday/multipart'
|
||||||
|
|
||||||
# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile
|
# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile
|
||||||
|
|
||||||
# Media attachments like photos, videos can be clubbed together and sent as a media group
|
# Media attachments like photos, videos can be clubbed together and sent as a media group
|
||||||
@@ -111,17 +113,33 @@ class Telegram::SendAttachmentsService
|
|||||||
|
|
||||||
def send_file(chat_id, file_path, reply_to_message_id)
|
def send_file(chat_id, file_path, reply_to_message_id)
|
||||||
File.open(file_path, 'rb') do |file|
|
File.open(file_path, 'rb') do |file|
|
||||||
HTTParty.post("#{channel.telegram_api_url}/sendDocument",
|
file_name = File.basename(file_path)
|
||||||
body: {
|
mime_type = Marcel::MimeType.for(name: file_name) || 'application/octet-stream'
|
||||||
chat_id: chat_id,
|
|
||||||
**business_connection_body,
|
payload = { chat_id: chat_id, document: Faraday::Multipart::FilePart.new(file, mime_type, file_name) }
|
||||||
document: file,
|
payload[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
||||||
reply_to_message_id: reply_to_message_id
|
payload.merge!(business_connection_body)
|
||||||
},
|
|
||||||
multipart: true)
|
response = multipart_post_connection.post("#{channel.telegram_api_url}/sendDocument", payload)
|
||||||
|
parse_faraday_response(response)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def multipart_post_connection
|
||||||
|
@multipart_post_connection ||= Faraday.new do |f|
|
||||||
|
f.request :multipart
|
||||||
|
f.options.timeout = 300
|
||||||
|
f.options.open_timeout = 60
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_faraday_response(response)
|
||||||
|
parsed = JSON.parse(response.body)
|
||||||
|
OpenStruct.new(success?: response.success?, parsed_response: parsed)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
OpenStruct.new(success?: false, parsed_response: { 'ok' => false, 'error_code' => response.status, 'description' => response.reason_phrase })
|
||||||
|
end
|
||||||
|
|
||||||
def handle_response(response)
|
def handle_response(response)
|
||||||
return true if response.success?
|
return true if response.success?
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ class Twilio::DeliveryStatusService
|
|||||||
@twilio_channel ||= if params[:MessagingServiceSid].present?
|
@twilio_channel ||= if params[:MessagingServiceSid].present?
|
||||||
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
|
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
|
||||||
elsif params[:AccountSid].present? && params[:From].present?
|
elsif params[:AccountSid].present? && params[:From].present?
|
||||||
::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From])
|
::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: params[:From])
|
||||||
end
|
end
|
||||||
|
log_channel_not_found if @twilio_channel.blank?
|
||||||
|
@twilio_channel
|
||||||
end
|
end
|
||||||
|
|
||||||
def message
|
def message
|
||||||
@@ -56,4 +58,14 @@ class Twilio::DeliveryStatusService
|
|||||||
|
|
||||||
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
|
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def log_channel_not_found
|
||||||
|
Rails.logger.warn(
|
||||||
|
'[TWILIO] Delivery status channel lookup failed ' \
|
||||||
|
"account_sid=#{params[:AccountSid]} " \
|
||||||
|
"from=#{params[:From]} " \
|
||||||
|
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||||
|
"message_sid=#{params[:MessageSid]}"
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -26,12 +26,23 @@ class Twilio::IncomingMessageService
|
|||||||
def twilio_channel
|
def twilio_channel
|
||||||
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
|
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
|
||||||
if params[:AccountSid].present? && params[:To].present?
|
if params[:AccountSid].present? && params[:To].present?
|
||||||
@twilio_channel ||= ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid],
|
@twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
|
||||||
phone_number: params[:To])
|
phone_number: params[:To])
|
||||||
end
|
end
|
||||||
|
log_channel_not_found if @twilio_channel.blank?
|
||||||
@twilio_channel
|
@twilio_channel
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def log_channel_not_found
|
||||||
|
Rails.logger.warn(
|
||||||
|
'[TWILIO] Incoming message channel lookup failed ' \
|
||||||
|
"account_sid=#{params[:AccountSid]} " \
|
||||||
|
"to=#{params[:To]} " \
|
||||||
|
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||||
|
"sms_sid=#{params[:SmsSid]}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def inbox
|
def inbox
|
||||||
@inbox ||= twilio_channel.inbox
|
@inbox ||= twilio_channel.inbox
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,19 +28,19 @@ class Whatsapp::IncomingMessageBaseService
|
|||||||
# if the webhook event is a reaction or an ephermal message or an unsupported message.
|
# if the webhook event is a reaction or an ephermal message or an unsupported message.
|
||||||
return if unprocessable_message_type?(message_type)
|
return if unprocessable_message_type?(message_type)
|
||||||
|
|
||||||
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
|
# Multiple webhook events can be received for the same message due to
|
||||||
# business manager account. While we have not found the core reason yet, the following line ensure that
|
# misconfigurations in the Meta business manager account.
|
||||||
# there are no duplicate messages created.
|
# We use an atomic Redis SET NX to prevent concurrent workers from both
|
||||||
return if find_message_by_source_id(messages_data.first[:id]) || message_under_process?
|
# processing the same message simultaneously.
|
||||||
|
return if find_message_by_source_id(messages_data.first[:id])
|
||||||
|
return unless lock_message_source_id!
|
||||||
|
|
||||||
cache_message_source_id_in_redis
|
|
||||||
set_contact
|
set_contact
|
||||||
return unless @contact
|
return unless @contact
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
set_conversation
|
set_conversation
|
||||||
create_messages
|
create_messages
|
||||||
clear_message_source_id_from_redis
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -69,20 +69,9 @@ module Whatsapp::IncomingMessageServiceHelpers
|
|||||||
@message = Message.find_by(source_id: source_id)
|
@message = Message.find_by(source_id: source_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_under_process?
|
def lock_message_source_id!
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
|
return false if messages_data.blank?
|
||||||
Redis::Alfred.get(key)
|
|
||||||
end
|
|
||||||
|
|
||||||
def cache_message_source_id_in_redis
|
Whatsapp::MessageDedupLock.new(messages_data.first[:id]).acquire!
|
||||||
return if messages_data.blank?
|
|
||||||
|
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
|
|
||||||
::Redis::Alfred.setex(key, true)
|
|
||||||
end
|
|
||||||
|
|
||||||
def clear_message_source_id_from_redis
|
|
||||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
|
|
||||||
::Redis::Alfred.delete(key)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Atomic dedup lock for WhatsApp incoming messages.
|
||||||
|
#
|
||||||
|
# Meta can deliver the same webhook event multiple times. This lock uses
|
||||||
|
# Redis SET NX EX to ensure only one worker processes a given source_id.
|
||||||
|
class Whatsapp::MessageDedupLock
|
||||||
|
KEY_PREFIX = Redis::RedisKeys::MESSAGE_SOURCE_KEY
|
||||||
|
DEFAULT_TTL = 1.day.to_i
|
||||||
|
|
||||||
|
def initialize(source_id, ttl: DEFAULT_TTL)
|
||||||
|
@key = format(KEY_PREFIX, id: source_id)
|
||||||
|
@ttl = ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns true when the lock is acquired (caller should proceed).
|
||||||
|
# Returns false when another worker already holds the lock.
|
||||||
|
def acquire!
|
||||||
|
::Redis::Alfred.set(@key, true, nx: true, ex: @ttl)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -241,3 +241,7 @@
|
|||||||
display_name: Required Conversation Attributes
|
display_name: Required Conversation Attributes
|
||||||
enabled: false
|
enabled: false
|
||||||
premium: true
|
premium: true
|
||||||
|
- name: advanced_assignment
|
||||||
|
display_name: Advanced Assignment
|
||||||
|
enabled: false
|
||||||
|
premium: true
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ en:
|
|||||||
invalid_email: 'Please enter a valid email address'
|
invalid_email: 'Please enter a valid email address'
|
||||||
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
|
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
|
||||||
messages:
|
messages:
|
||||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
reset_password: Request for password reset is successful. A email with instructions will be sent to your email if it exists.
|
||||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
|
||||||
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
|
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
|
||||||
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
|
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
|
||||||
saml_not_available: SAML authentication is not available in this installation.
|
saml_not_available: SAML authentication is not available in this installation.
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
module Captain::ChatResponseHelper
|
module Captain::ChatResponseHelper
|
||||||
|
include Integrations::LlmInstrumentationConstants
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def build_response(response)
|
def build_response(response)
|
||||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||||
|
|
||||||
parsed = parse_json_response(response.content)
|
parsed = parse_json_response(response.content)
|
||||||
|
apply_credit_usage_metadata(parsed)
|
||||||
|
|
||||||
persist_message(parsed, 'assistant')
|
persist_message(parsed, 'assistant')
|
||||||
parsed
|
parsed
|
||||||
@@ -19,6 +22,26 @@ module Captain::ChatResponseHelper
|
|||||||
{ 'content' => content }
|
{ 'content' => content }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def apply_credit_usage_metadata(parsed_response)
|
||||||
|
return unless captain_v1_assistant?
|
||||||
|
|
||||||
|
OpenTelemetry::Trace.current_span.set_attribute(
|
||||||
|
format(ATTR_LANGFUSE_METADATA, 'credit_used'),
|
||||||
|
credit_used_for_response?(parsed_response).to_s
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "#{self.class.name} Assistant: #{@assistant.id}, Failed to set credit usage metadata: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def credit_used_for_response?(parsed_response)
|
||||||
|
response = parsed_response['response']
|
||||||
|
response.present? && response != 'conversation_handoff'
|
||||||
|
end
|
||||||
|
|
||||||
|
def captain_v1_assistant?
|
||||||
|
feature_name == 'assistant' && !@assistant.account.feature_enabled?('captain_integration_v2')
|
||||||
|
end
|
||||||
|
|
||||||
def persist_thinking_message(tool_call)
|
def persist_thinking_message(tool_call)
|
||||||
return if @copilot_thread.blank?
|
return if @copilot_thread.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_out_of_office_message_if_applicable
|
def send_out_of_office_message_if_applicable
|
||||||
|
# Campaign conversations should never receive OOO templates — the campaign itself
|
||||||
|
# serves as the initial outreach, and OOO would be confusing in that context.
|
||||||
|
return if @conversation.campaign.present?
|
||||||
|
|
||||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
class Messages::AudioTranscriptionJob < ApplicationJob
|
class Messages::AudioTranscriptionJob < ApplicationJob
|
||||||
queue_as :low
|
queue_as :low
|
||||||
|
|
||||||
|
discard_on Faraday::BadRequestError do |job, error|
|
||||||
|
log_context = {
|
||||||
|
attachment_id: job.arguments.first,
|
||||||
|
job_id: job.job_id,
|
||||||
|
status_code: error.response&.dig(:status)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rails.logger.warn("Discarding audio transcription job due to bad request: #{log_context}")
|
||||||
|
end
|
||||||
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
|
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
|
||||||
|
|
||||||
def perform(attachment_id)
|
def perform(attachment_id)
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ module Enterprise::Account
|
|||||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||||
def manually_managed_features; end
|
def manually_managed_features; end
|
||||||
|
|
||||||
|
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
|
||||||
|
def selected_feature_flags=(features)
|
||||||
|
super
|
||||||
|
sync_assignment_features
|
||||||
|
end
|
||||||
|
|
||||||
def mark_for_deletion(reason = 'manual_deletion')
|
def mark_for_deletion(reason = 'manual_deletion')
|
||||||
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
||||||
|
|
||||||
@@ -31,4 +37,21 @@ module Enterprise::Account
|
|||||||
def saml_enabled?
|
def saml_enabled?
|
||||||
saml_settings&.saml_enabled? || false
|
saml_settings&.saml_enabled? || false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def sync_assignment_features
|
||||||
|
if feature_enabled?('assignment_v2')
|
||||||
|
# Enable advanced_assignment for Business/Enterprise plans
|
||||||
|
send('feature_advanced_assignment=', true) if business_or_enterprise_plan?
|
||||||
|
else
|
||||||
|
# Disable advanced_assignment when assignment_v2 is disabled
|
||||||
|
send('feature_advanced_assignment=', false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def business_or_enterprise_plan?
|
||||||
|
plan_name = custom_attributes['plan_name']
|
||||||
|
%w[Business Enterprise].include?(plan_name)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
|
|||||||
save
|
save
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def email_transcript_enabled?
|
||||||
|
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
|
||||||
|
return true if default_plan.blank?
|
||||||
|
|
||||||
|
plan_name.present? && plan_name != default_plan['name']
|
||||||
|
end
|
||||||
|
|
||||||
def email_rate_limit
|
def email_rate_limit
|
||||||
account_limit || plan_email_limit || global_limit || default_limit
|
account_limit || plan_email_limit || global_limit || default_limit
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
require 'agents'
|
require 'agents'
|
||||||
|
require 'agents/instrumentation'
|
||||||
|
|
||||||
class Captain::Assistant::AgentRunnerService
|
class Captain::Assistant::AgentRunnerService
|
||||||
|
include Integrations::LlmInstrumentationConstants
|
||||||
|
|
||||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||||
id display_id inbox_id contact_id status priority
|
id display_id inbox_id contact_id status priority
|
||||||
label_list custom_attributes additional_attributes
|
label_list custom_attributes additional_attributes
|
||||||
@@ -22,7 +25,9 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
context = build_context(message_history)
|
context = build_context(message_history)
|
||||||
message_to_process = extract_last_user_message(message_history)
|
message_to_process = extract_last_user_message(message_history)
|
||||||
runner = Agents::Runner.with_agents(*agents)
|
runner = Agents::Runner.with_agents(*agents)
|
||||||
|
runner = add_usage_metadata_callback(runner)
|
||||||
runner = add_callbacks_to_runner(runner) if @callbacks.any?
|
runner = add_callbacks_to_runner(runner) if @callbacks.any?
|
||||||
|
install_instrumentation(runner)
|
||||||
result = runner.run(message_to_process, context: context, max_turns: 100)
|
result = runner.run(message_to_process, context: context, max_turns: 100)
|
||||||
|
|
||||||
process_agent_result(result)
|
process_agent_result(result)
|
||||||
@@ -50,6 +55,7 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
end
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
|
session_id: "#{@assistant.account_id}_#{@conversation&.display_id}",
|
||||||
conversation_history: conversation_history,
|
conversation_history: conversation_history,
|
||||||
state: build_state
|
state: build_state
|
||||||
}
|
}
|
||||||
@@ -124,6 +130,31 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
[assistant_agent] + scenario_agents
|
[assistant_agent] + scenario_agents
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def install_instrumentation(runner)
|
||||||
|
return unless ChatwootApp.otel_enabled?
|
||||||
|
|
||||||
|
Agents::Instrumentation.install(
|
||||||
|
runner,
|
||||||
|
tracer: OpentelemetryConfig.tracer,
|
||||||
|
trace_name: 'llm.captain_v2',
|
||||||
|
span_attributes: {
|
||||||
|
ATTR_LANGFUSE_TAGS => ['captain_v2'].to_json
|
||||||
|
},
|
||||||
|
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dynamic_trace_attributes(context_wrapper)
|
||||||
|
state = context_wrapper&.context&.dig(:state) || {}
|
||||||
|
conversation = state[:conversation] || {}
|
||||||
|
{
|
||||||
|
ATTR_LANGFUSE_USER_ID => state[:account_id],
|
||||||
|
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
|
||||||
|
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
|
||||||
|
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id]
|
||||||
|
}.compact.transform_values(&:to_s)
|
||||||
|
end
|
||||||
|
|
||||||
def add_callbacks_to_runner(runner)
|
def add_callbacks_to_runner(runner)
|
||||||
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
|
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
|
||||||
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
|
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
|
||||||
@@ -132,6 +163,36 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
runner
|
runner
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def add_usage_metadata_callback(runner)
|
||||||
|
return runner unless ChatwootApp.otel_enabled?
|
||||||
|
|
||||||
|
handoff_tool_name = Captain::Tools::HandoffTool.new(@assistant).name
|
||||||
|
|
||||||
|
runner.on_tool_complete do |tool_name, _tool_result, context_wrapper|
|
||||||
|
track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
|
||||||
|
end
|
||||||
|
|
||||||
|
runner.on_run_complete do |_agent_name, _result, context_wrapper|
|
||||||
|
write_credits_used_metadata(context_wrapper)
|
||||||
|
end
|
||||||
|
runner
|
||||||
|
end
|
||||||
|
|
||||||
|
def track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
|
||||||
|
return unless context_wrapper&.context
|
||||||
|
return unless tool_name.to_s == handoff_tool_name
|
||||||
|
|
||||||
|
context_wrapper.context[:captain_v2_handoff_tool_called] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_credits_used_metadata(context_wrapper)
|
||||||
|
root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
|
||||||
|
return unless root_span
|
||||||
|
|
||||||
|
credits_used = !context_wrapper.context[:captain_v2_handoff_tool_called]
|
||||||
|
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credits_used'), credits_used)
|
||||||
|
end
|
||||||
|
|
||||||
def add_agent_thinking_callback(runner)
|
def add_agent_thinking_callback(runner)
|
||||||
runner.on_agent_thinking do |*args|
|
runner.on_agent_thinking do |*args|
|
||||||
@callbacks[:on_agent_thinking].call(*args)
|
@callbacks[:on_agent_thinking].call(*args)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
|
||||||
|
MODEL = 'gpt-4.1-nano'.freeze
|
||||||
|
|
||||||
|
pattr_initialize [:account!]
|
||||||
|
|
||||||
|
def translate(query, target_language:)
|
||||||
|
return query if query_in_target_language?(query)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{ role: 'system', content: system_prompt(target_language) },
|
||||||
|
{ role: 'user', content: query }
|
||||||
|
]
|
||||||
|
|
||||||
|
response = make_api_call(model: MODEL, messages: messages)
|
||||||
|
return query if response[:error]
|
||||||
|
|
||||||
|
response[:message].strip
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "TranslateQueryService failed: #{e.message}, falling back to original query"
|
||||||
|
query
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def event_name
|
||||||
|
'translate_query'
|
||||||
|
end
|
||||||
|
|
||||||
|
def query_in_target_language?(query)
|
||||||
|
detector = CLD3::NNetLanguageIdentifier.new(0, 1000)
|
||||||
|
result = detector.find_language(query)
|
||||||
|
|
||||||
|
result.reliable? && result.language == account_language_code
|
||||||
|
rescue StandardError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_language_code
|
||||||
|
account.locale&.split('_')&.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def system_prompt(target_language)
|
||||||
|
<<~SYSTEM_PROMPT_MESSAGE
|
||||||
|
You are a helpful assistant that translates queries from one language to another.
|
||||||
|
Translate the query to #{target_language}.
|
||||||
|
Return just the translated query, no other text.
|
||||||
|
SYSTEM_PROMPT_MESSAGE
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,11 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
|||||||
def execute(query:)
|
def execute(query:)
|
||||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||||
|
|
||||||
responses = assistant.responses.approved.search(query)
|
translated_query = Captain::Llm::TranslateQueryService
|
||||||
|
.new(account: assistant.account)
|
||||||
|
.translate(query, target_language: assistant.account.locale_english_name)
|
||||||
|
|
||||||
|
responses = assistant.responses.approved.search(translated_query)
|
||||||
|
|
||||||
return 'No FAQs found for the given query' if responses.empty?
|
return 'No FAQs found for the given query' if responses.empty?
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
|
|||||||
def execute(query:)
|
def execute(query:)
|
||||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||||
|
|
||||||
responses = search_responses(query)
|
translated_query = Captain::Llm::TranslateQueryService
|
||||||
|
.new(account: @account)
|
||||||
|
.translate(query, target_language: @account.locale_english_name)
|
||||||
|
|
||||||
|
responses = search_responses(translated_query)
|
||||||
return 'No FAQs found for the given query' if responses.empty?
|
return 'No FAQs found for the given query' if responses.empty?
|
||||||
|
|
||||||
responses.map { |response| format_response(response) }.join
|
responses.map { |response| format_response(response) }.join
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ module Enterprise::AutoAssignment::AssignmentService
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Extend agent finding to add capacity checks
|
# Extend agent finding to add capacity checks
|
||||||
def find_available_agent
|
def find_available_agent(conversation = nil)
|
||||||
agents = filter_agents_by_rate_limit(inbox.available_agents)
|
agents = filter_agents_by_team(inbox.available_agents, conversation)
|
||||||
|
return nil if agents.nil?
|
||||||
|
|
||||||
|
agents = filter_agents_by_rate_limit(agents)
|
||||||
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
|
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
|
||||||
return nil if agents.empty?
|
return nil if agents.empty?
|
||||||
|
|
||||||
selector = policy&.balanced? ? balanced_selector : round_robin_selector
|
# Use balanced selector only if advanced_assignment feature is enabled
|
||||||
|
selector = policy&.balanced? && account.feature_enabled?('advanced_assignment') ? balanced_selector : round_robin_selector
|
||||||
selector.select_agent(agents)
|
selector.select_agent(agents)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ module Enterprise::AutoAssignment::AssignmentService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def capacity_filtering_enabled?
|
def capacity_filtering_enabled?
|
||||||
account.feature_enabled?('assignment_v2') &&
|
account.feature_enabled?('advanced_assignment') &&
|
||||||
account.account_users.joins(:agent_capacity_policy).exists?
|
account.account_users.joins(:agent_capacity_policy).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
# Additional features available starting with the Business plan
|
# Additional features available starting with the Business plan
|
||||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes].freeze
|
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
|
||||||
|
|
||||||
# Additional features available only in the Enterprise plan
|
# Additional features available only in the Enterprise plan
|
||||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ module Enterprise::MessageTemplates::HookExecutionService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_out_of_office_message_after_handoff
|
def send_out_of_office_message_after_handoff
|
||||||
|
# Campaign conversations should never receive OOO templates — the campaign itself
|
||||||
|
# serves as the initial outreach, and OOO would be confusing in that context.
|
||||||
|
return if conversation.campaign.present?
|
||||||
|
|
||||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,20 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def fetch_audio_file
|
def fetch_audio_file
|
||||||
|
blob = attachment.file.blob
|
||||||
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
|
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
|
||||||
FileUtils.mkdir_p(temp_dir)
|
FileUtils.mkdir_p(temp_dir)
|
||||||
temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}")
|
temp_file_name = "#{blob.key}-#{blob.filename}"
|
||||||
|
|
||||||
|
if blob.filename.extension_without_delimiter.blank?
|
||||||
|
extension = extension_from_content_type(blob.content_type)
|
||||||
|
temp_file_name = "#{temp_file_name}.#{extension}" if extension.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
temp_file_path = File.join(temp_dir, temp_file_name)
|
||||||
|
|
||||||
File.open(temp_file_path, 'wb') do |file|
|
File.open(temp_file_path, 'wb') do |file|
|
||||||
attachment.file.blob.open do |blob_file|
|
blob.open do |blob_file|
|
||||||
IO.copy_stream(blob_file, file)
|
IO.copy_stream(blob_file, file)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -49,13 +57,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
|||||||
return transcribed_text if transcribed_text.present?
|
return transcribed_text if transcribed_text.present?
|
||||||
|
|
||||||
temp_file_path = fetch_audio_file
|
temp_file_path = fetch_audio_file
|
||||||
|
|
||||||
transcribed_text = nil
|
transcribed_text = nil
|
||||||
|
|
||||||
File.open(temp_file_path, 'rb') do |file|
|
File.open(temp_file_path, 'rb') do |file|
|
||||||
response = @client.audio.transcribe(
|
response = @client.audio.transcribe(
|
||||||
parameters: {
|
parameters: {
|
||||||
model: 'whisper-1',
|
model: WHISPER_MODEL,
|
||||||
file: file,
|
file: file,
|
||||||
temperature: 0.4
|
temperature: 0.4
|
||||||
}
|
}
|
||||||
@@ -63,10 +70,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
|||||||
transcribed_text = response['text']
|
transcribed_text = response['text']
|
||||||
end
|
end
|
||||||
|
|
||||||
FileUtils.rm_f(temp_file_path)
|
|
||||||
|
|
||||||
update_transcription(transcribed_text)
|
update_transcription(transcribed_text)
|
||||||
transcribed_text
|
transcribed_text
|
||||||
|
ensure
|
||||||
|
FileUtils.rm_f(temp_file_path) if temp_file_path.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def instrumentation_params(file_path)
|
def instrumentation_params(file_path)
|
||||||
@@ -90,4 +97,15 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
|||||||
|
|
||||||
message.reindex
|
message.reindex
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def extension_from_content_type(content_type)
|
||||||
|
subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s
|
||||||
|
return if subtype.blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
'x-m4a' => 'm4a',
|
||||||
|
'x-wav' => 'wav',
|
||||||
|
'x-mp3' => 'mp3'
|
||||||
|
}.fetch(subtype, subtype)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_out_of_office_message_if_applicable(conversation)
|
def send_out_of_office_message_if_applicable(conversation)
|
||||||
|
# Campaign conversations should never receive OOO templates — the campaign itself
|
||||||
|
# serves as the initial outreach, and OOO would be confusing in that context.
|
||||||
|
return if conversation.campaign.present?
|
||||||
|
|
||||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
module Captain::ToolInstrumentation
|
module Captain::ToolInstrumentation
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
include Integrations::LlmInstrumentationConstants
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
@@ -10,15 +11,11 @@ module Captain::ToolInstrumentation
|
|||||||
response = nil
|
response = nil
|
||||||
executed = false
|
executed = false
|
||||||
tracer.in_span(params[:span_name]) do |span|
|
tracer.in_span(params[:span_name]) do |span|
|
||||||
span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id]
|
set_tool_session_attributes(span, params)
|
||||||
span.set_attribute('langfuse.tags', [params[:feature_name]].to_json)
|
|
||||||
span.set_attribute('langfuse.observation.input', params[:messages].to_json)
|
|
||||||
|
|
||||||
response = yield
|
response = yield
|
||||||
executed = true
|
executed = true
|
||||||
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
|
||||||
# Output just the message for cleaner Langfuse display
|
set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
|
||||||
span.set_attribute('langfuse.observation.output', response[:message] || response.to_json)
|
|
||||||
end
|
end
|
||||||
response
|
response
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
@@ -26,17 +23,32 @@ module Captain::ToolInstrumentation
|
|||||||
executed ? response : yield
|
executed ? response : yield
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_tool_session_attributes(span, params)
|
||||||
|
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||||
|
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present?
|
||||||
|
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
|
||||||
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_tool_session_error_attributes(span, response)
|
||||||
|
error = response[:error] || response['error']
|
||||||
|
return if error.blank?
|
||||||
|
|
||||||
|
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
|
||||||
|
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
|
||||||
|
end
|
||||||
|
|
||||||
def record_generation(chat, message, model)
|
def record_generation(chat, message, model)
|
||||||
return unless ChatwootApp.otel_enabled?
|
return unless ChatwootApp.otel_enabled?
|
||||||
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
|
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
|
||||||
|
|
||||||
tracer.in_span("llm.#{event_name}.generation") do |span|
|
tracer.in_span("llm.#{event_name}.generation") do |span|
|
||||||
span.set_attribute('gen_ai.system', 'openai')
|
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||||
span.set_attribute('gen_ai.request.model', model)
|
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
|
||||||
span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens)
|
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens)
|
||||||
span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens)
|
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens)
|
||||||
span.set_attribute('langfuse.observation.input', format_chat_messages(chat))
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, format_chat_messages(chat))
|
||||||
span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content)
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
|
||||||
end
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.warn "Failed to record generation: #{e.message}"
|
Rails.logger.warn "Failed to record generation: #{e.message}"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ module Integrations::LlmInstrumentation
|
|||||||
result = yield
|
result = yield
|
||||||
executed = true
|
executed = true
|
||||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||||
|
set_error_attributes(span, result) if result.is_a?(Hash)
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
@@ -50,9 +51,11 @@ module Integrations::LlmInstrumentation
|
|||||||
return yield unless ChatwootApp.otel_enabled?
|
return yield unless ChatwootApp.otel_enabled?
|
||||||
|
|
||||||
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
|
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
|
||||||
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
|
||||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
|
||||||
result = yield
|
result = yield
|
||||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||||
|
set_error_attributes(span, result) if result.is_a?(Hash)
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ module Integrations::LlmInstrumentationConstants
|
|||||||
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
|
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
|
||||||
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
|
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
|
||||||
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
|
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
|
||||||
|
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
|
||||||
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
|
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
|
||||||
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
|
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans
|
|||||||
|
|
||||||
tool_name = tool_call.name.to_s
|
tool_name = tool_call.name.to_s
|
||||||
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
|
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
|
||||||
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
|
||||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
|
||||||
|
|
||||||
@pending_tool_spans ||= []
|
@pending_tool_spans ||= []
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
|||||||
def send_message
|
def send_message
|
||||||
post_message if message_content.present?
|
post_message if message_content.present?
|
||||||
upload_files if message.attachments.any?
|
upload_files if message.attachments.any?
|
||||||
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
|
rescue Slack::Web::Api::Errors::IsArchived, Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope,
|
||||||
|
Slack::Web::Api::Errors::InvalidAuth,
|
||||||
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
|
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
hook.prompt_reauthorization!
|
hook.prompt_reauthorization!
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ module Linear::Mutations
|
|||||||
def self.graphql_value(value)
|
def self.graphql_value(value)
|
||||||
case value
|
case value
|
||||||
when String
|
when String
|
||||||
# Strings must be enclosed in double quotes
|
value.to_json
|
||||||
"\"#{value.gsub("\n", '\\n')}\""
|
|
||||||
when Array
|
when Array
|
||||||
# Arrays need to be recursively converted
|
|
||||||
"[#{value.map { |v| graphql_value(v) }.join(', ')}]"
|
"[#{value.map { |v| graphql_value(v) }.join(', ')}]"
|
||||||
else
|
else
|
||||||
# Other types (numbers, booleans) can be directly converted to strings
|
|
||||||
value.to_s
|
value.to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -47,7 +44,7 @@ module Linear::Mutations
|
|||||||
|
|
||||||
<<~GRAPHQL
|
<<~GRAPHQL
|
||||||
mutation {
|
mutation {
|
||||||
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) {
|
attachmentLinkURL(url: #{graphql_value(link)}, issueId: #{graphql_value(issue_id)}, title: #{graphql_value(title)}#{user_params_str}) {
|
||||||
success
|
success
|
||||||
attachment {
|
attachment {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ module Linear::Queries
|
|||||||
def self.search_issue(term)
|
def self.search_issue(term)
|
||||||
<<~GRAPHQL
|
<<~GRAPHQL
|
||||||
query {
|
query {
|
||||||
searchIssues(term: "#{term}") {
|
searchIssues(term: #{Linear::Mutations.graphql_value(term)}) {
|
||||||
nodes {
|
nodes {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module LlmConstants
|
module LlmConstants
|
||||||
DEFAULT_MODEL = 'gpt-4.1-mini'
|
DEFAULT_MODEL = 'gpt-4.1'
|
||||||
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||||
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
|
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -36,16 +36,21 @@ class Webhooks::Trigger
|
|||||||
|
|
||||||
case @webhook_type
|
case @webhook_type
|
||||||
when :agent_bot_webhook
|
when :agent_bot_webhook
|
||||||
conversation = message.conversation
|
update_conversation_status(message)
|
||||||
return unless conversation&.pending?
|
|
||||||
|
|
||||||
conversation.open!
|
|
||||||
create_agent_bot_error_activity(conversation)
|
|
||||||
when :api_inbox_webhook
|
when :api_inbox_webhook
|
||||||
update_message_status(error)
|
update_message_status(error)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_conversation_status(message)
|
||||||
|
conversation = message.conversation
|
||||||
|
return unless conversation&.pending?
|
||||||
|
return if conversation&.account&.keep_pending_on_bot_failure
|
||||||
|
|
||||||
|
conversation.open!
|
||||||
|
create_agent_bot_error_activity(conversation)
|
||||||
|
end
|
||||||
|
|
||||||
def create_agent_bot_error_activity(conversation)
|
def create_agent_bot_error_activity(conversation)
|
||||||
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
|
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
|
||||||
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
|
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
|
||||||
|
|||||||
+98
-57
@@ -1,67 +1,108 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>The page you were looking for doesn't exist (404)</title>
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Page not found</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
background-color: #EFEFEF;
|
body {
|
||||||
color: #2E2F30;
|
min-height: 100vh;
|
||||||
text-align: center;
|
display: flex;
|
||||||
font-family: arial, sans-serif;
|
flex-direction: column;
|
||||||
margin: 0;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
|
||||||
div.dialog {
|
background: rgba(39, 129, 246, 0.05);
|
||||||
width: 95%;
|
color: rgb(28, 32, 36);
|
||||||
max-width: 33em;
|
padding: 2rem 1.5rem;
|
||||||
margin: 4em auto 0;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
.page {
|
||||||
div.dialog > div {
|
display: flex;
|
||||||
border: 1px solid #CCC;
|
flex-direction: column;
|
||||||
border-right-color: #999;
|
align-items: center;
|
||||||
border-left-color: #999;
|
width: 100%;
|
||||||
border-bottom-color: #BBB;
|
max-width: 28rem;
|
||||||
border-top: #B00100 solid 4px;
|
}
|
||||||
border-top-left-radius: 9px;
|
.error-number {
|
||||||
border-top-right-radius: 9px;
|
font-size: 8rem;
|
||||||
background-color: white;
|
font-weight: 700;
|
||||||
padding: 7px 12% 0;
|
line-height: 1;
|
||||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
letter-spacing: -0.04em;
|
||||||
}
|
background: linear-gradient(180deg, rgb(39, 129, 246) 0%, rgb(155, 195, 252) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
h1 {
|
-webkit-text-fill-color: transparent;
|
||||||
font-size: 100%;
|
background-clip: text;
|
||||||
color: #730E15;
|
margin-bottom: 1.5rem;
|
||||||
line-height: 1.5em;
|
}
|
||||||
}
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
div.dialog > p {
|
font-weight: 600;
|
||||||
margin: 0 0 1em;
|
line-height: 1.3;
|
||||||
padding: 1em;
|
text-align: center;
|
||||||
background-color: #F7F7F7;
|
margin-bottom: 0.5rem;
|
||||||
border: 1px solid #CCC;
|
}
|
||||||
border-right-color: #999;
|
.description {
|
||||||
border-left-color: #999;
|
font-size: 0.9375rem;
|
||||||
border-bottom-color: #999;
|
color: rgb(96, 100, 108);
|
||||||
border-bottom-left-radius: 4px;
|
line-height: 1.6;
|
||||||
border-bottom-right-radius: 4px;
|
text-align: center;
|
||||||
border-top-color: #DADADA;
|
margin-bottom: 2.5rem;
|
||||||
color: #666;
|
}
|
||||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
.btn {
|
||||||
}
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgb(39, 129, 246);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgb(16, 115, 233); }
|
||||||
|
.btn svg { width: 1.125rem; height: 1.125rem; }
|
||||||
|
.divider {
|
||||||
|
width: 3rem;
|
||||||
|
height: 2px;
|
||||||
|
background: rgb(224, 225, 230);
|
||||||
|
border-radius: 1px;
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
}
|
||||||
|
.help {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: rgb(139, 141, 152);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
|
||||||
|
.error-number { background: linear-gradient(180deg, rgb(126, 182, 255) 0%, rgb(40, 89, 156) 100%); -webkit-background-clip: text; background-clip: text; }
|
||||||
|
.description { color: rgb(176, 180, 186); }
|
||||||
|
.divider { background: rgb(46, 49, 53); }
|
||||||
|
.help { color: rgb(105, 110, 119); }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.error-number { font-size: 5rem; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- This file lives in public/404.html -->
|
<div class="page">
|
||||||
<div class="dialog">
|
<div class="error-number">404</div>
|
||||||
<div>
|
<h1>Page not found</h1>
|
||||||
<h1>The page you were looking for doesn't exist.</h1>
|
<p class="description">The page you're looking for doesn't exist or has been moved.</p>
|
||||||
<p>You may have mistyped the address or the page may have moved.</p>
|
<a href="/" class="btn">
|
||||||
</div>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
|
||||||
<p>If you are the application owner check the logs for more information.</p>
|
Back to home
|
||||||
|
</a>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<p class="help">If you think this is a mistake, please reach out to support.</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+103
-57
@@ -1,67 +1,113 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>The change you wanted was rejected (422)</title>
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Request rejected</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
background-color: #EFEFEF;
|
body {
|
||||||
color: #2E2F30;
|
min-height: 100vh;
|
||||||
text-align: center;
|
display: flex;
|
||||||
font-family: arial, sans-serif;
|
flex-direction: column;
|
||||||
margin: 0;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
|
||||||
div.dialog {
|
background: rgba(39, 129, 246, 0.05);
|
||||||
width: 95%;
|
color: rgb(28, 32, 36);
|
||||||
max-width: 33em;
|
padding: 2rem 1.5rem;
|
||||||
margin: 4em auto 0;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
.page {
|
||||||
div.dialog > div {
|
display: flex;
|
||||||
border: 1px solid #CCC;
|
flex-direction: column;
|
||||||
border-right-color: #999;
|
align-items: center;
|
||||||
border-left-color: #999;
|
width: 100%;
|
||||||
border-bottom-color: #BBB;
|
max-width: 28rem;
|
||||||
border-top: #B00100 solid 4px;
|
}
|
||||||
border-top-left-radius: 9px;
|
.error-number {
|
||||||
border-top-right-radius: 9px;
|
font-size: 8rem;
|
||||||
background-color: white;
|
font-weight: 700;
|
||||||
padding: 7px 12% 0;
|
line-height: 1;
|
||||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
letter-spacing: -0.04em;
|
||||||
}
|
background: linear-gradient(180deg, rgb(229, 70, 102) 0%, rgb(248, 191, 200) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
h1 {
|
-webkit-text-fill-color: transparent;
|
||||||
font-size: 100%;
|
background-clip: text;
|
||||||
color: #730E15;
|
margin-bottom: 1.5rem;
|
||||||
line-height: 1.5em;
|
}
|
||||||
}
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
div.dialog > p {
|
font-weight: 600;
|
||||||
margin: 0 0 1em;
|
line-height: 1.3;
|
||||||
padding: 1em;
|
text-align: center;
|
||||||
background-color: #F7F7F7;
|
margin-bottom: 0.5rem;
|
||||||
border: 1px solid #CCC;
|
}
|
||||||
border-right-color: #999;
|
.description {
|
||||||
border-left-color: #999;
|
font-size: 0.9375rem;
|
||||||
border-bottom-color: #999;
|
color: rgb(96, 100, 108);
|
||||||
border-bottom-left-radius: 4px;
|
line-height: 1.6;
|
||||||
border-bottom-right-radius: 4px;
|
text-align: center;
|
||||||
border-top-color: #DADADA;
|
margin-bottom: 2.5rem;
|
||||||
color: #666;
|
}
|
||||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
.btn {
|
||||||
}
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgb(39, 129, 246);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgb(16, 115, 233); }
|
||||||
|
.btn svg { width: 1.125rem; height: 1.125rem; }
|
||||||
|
.divider {
|
||||||
|
width: 3rem;
|
||||||
|
height: 2px;
|
||||||
|
background: rgb(224, 225, 230);
|
||||||
|
border-radius: 1px;
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
}
|
||||||
|
.help {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: rgb(139, 141, 152);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.help a {
|
||||||
|
color: rgb(39, 129, 246);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.help a:hover { text-decoration: underline; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
|
||||||
|
.error-number { background: linear-gradient(180deg, rgb(255, 148, 157) 0%, rgb(136, 52, 71) 100%); -webkit-background-clip: text; background-clip: text; }
|
||||||
|
.description { color: rgb(176, 180, 186); }
|
||||||
|
.divider { background: rgb(46, 49, 53); }
|
||||||
|
.help { color: rgb(105, 110, 119); }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.error-number { font-size: 5rem; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- This file lives in public/422.html -->
|
<div class="page">
|
||||||
<div class="dialog">
|
<div class="error-number">422</div>
|
||||||
<div>
|
<h1>Request rejected</h1>
|
||||||
<h1>The change you wanted was rejected.</h1>
|
<p class="description">Your request couldn't be processed due to a security verification failure or invalid data.</p>
|
||||||
<p>Maybe you tried to change something you didn't have access to.</p>
|
<a href="/" class="btn">
|
||||||
</div>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
|
||||||
<p>If you are the application owner check the logs for more information.</p>
|
Back to home
|
||||||
|
</a>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<p class="help">If this keeps happening, try clearing your cookies and refreshing.</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+98
-56
@@ -1,66 +1,108 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>We're sorry, but something went wrong (500)</title>
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Something went wrong</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
background-color: #EFEFEF;
|
body {
|
||||||
color: #2E2F30;
|
min-height: 100vh;
|
||||||
text-align: center;
|
display: flex;
|
||||||
font-family: arial, sans-serif;
|
flex-direction: column;
|
||||||
margin: 0;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
|
||||||
div.dialog {
|
background: rgba(39, 129, 246, 0.05);
|
||||||
width: 95%;
|
color: rgb(28, 32, 36);
|
||||||
max-width: 33em;
|
padding: 2rem 1.5rem;
|
||||||
margin: 4em auto 0;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
.page {
|
||||||
div.dialog > div {
|
display: flex;
|
||||||
border: 1px solid #CCC;
|
flex-direction: column;
|
||||||
border-right-color: #999;
|
align-items: center;
|
||||||
border-left-color: #999;
|
width: 100%;
|
||||||
border-bottom-color: #BBB;
|
max-width: 28rem;
|
||||||
border-top: #B00100 solid 4px;
|
}
|
||||||
border-top-left-radius: 9px;
|
.error-number {
|
||||||
border-top-right-radius: 9px;
|
font-size: 8rem;
|
||||||
background-color: white;
|
font-weight: 700;
|
||||||
padding: 7px 12% 0;
|
line-height: 1;
|
||||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
letter-spacing: -0.04em;
|
||||||
}
|
background: linear-gradient(180deg, rgb(207, 74, 34) 0%, rgb(247, 164, 120) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
h1 {
|
-webkit-text-fill-color: transparent;
|
||||||
font-size: 100%;
|
background-clip: text;
|
||||||
color: #730E15;
|
margin-bottom: 1.5rem;
|
||||||
line-height: 1.5em;
|
}
|
||||||
}
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
div.dialog > p {
|
font-weight: 600;
|
||||||
margin: 0 0 1em;
|
line-height: 1.3;
|
||||||
padding: 1em;
|
text-align: center;
|
||||||
background-color: #F7F7F7;
|
margin-bottom: 0.5rem;
|
||||||
border: 1px solid #CCC;
|
}
|
||||||
border-right-color: #999;
|
.description {
|
||||||
border-left-color: #999;
|
font-size: 0.9375rem;
|
||||||
border-bottom-color: #999;
|
color: rgb(96, 100, 108);
|
||||||
border-bottom-left-radius: 4px;
|
line-height: 1.6;
|
||||||
border-bottom-right-radius: 4px;
|
text-align: center;
|
||||||
border-top-color: #DADADA;
|
margin-bottom: 2.5rem;
|
||||||
color: #666;
|
}
|
||||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
.btn {
|
||||||
}
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgb(39, 129, 246);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgb(16, 115, 233); }
|
||||||
|
.btn svg { width: 1.125rem; height: 1.125rem; }
|
||||||
|
.divider {
|
||||||
|
width: 3rem;
|
||||||
|
height: 2px;
|
||||||
|
background: rgb(224, 225, 230);
|
||||||
|
border-radius: 1px;
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
}
|
||||||
|
.help {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: rgb(139, 141, 152);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
|
||||||
|
.error-number { background: linear-gradient(180deg, rgb(255, 148, 114) 0%, rgb(148, 52, 27) 100%); -webkit-background-clip: text; background-clip: text; }
|
||||||
|
.description { color: rgb(176, 180, 186); }
|
||||||
|
.divider { background: rgb(46, 49, 53); }
|
||||||
|
.help { color: rgb(105, 110, 119); }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.error-number { font-size: 5rem; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- This file lives in public/500.html -->
|
<div class="page">
|
||||||
<div class="dialog">
|
<div class="error-number">500</div>
|
||||||
<div>
|
<h1>Something went wrong</h1>
|
||||||
<h1>We're sorry, but something went wrong.</h1>
|
<p class="description">An unexpected error occurred. Please refresh the page or try again shortly.</p>
|
||||||
</div>
|
<a href="/" class="btn">
|
||||||
<p>If you are the application owner check the logs for more information.</p>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
|
||||||
|
Back to home
|
||||||
|
</a>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<p class="help">If the problem persists, please try again in a few minutes.</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ RSpec.describe 'Conversations API', type: :request do
|
|||||||
context 'when it is an authenticated user who has access to the inbox' do
|
context 'when it is an authenticated user who has access to the inbox' do
|
||||||
before do
|
before do
|
||||||
create(:inbox_member, user: agent, inbox: inbox)
|
create(:inbox_member, user: agent, inbox: inbox)
|
||||||
|
create(:team_member, user: agent, team: team)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new conversation' do
|
it 'creates a new conversation' do
|
||||||
|
|||||||
@@ -144,13 +144,13 @@ RSpec.describe Concerns::Agentable do
|
|||||||
it 'returns default model when config not found' do
|
it 'returns default model when config not found' do
|
||||||
allow(InstallationConfig).to receive(:find_by).and_return(nil)
|
allow(InstallationConfig).to receive(:find_by).and_return(nil)
|
||||||
|
|
||||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-mini')
|
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns default model when config value is nil' do
|
it 'returns default model when config value is nil' do
|
||||||
allow(mock_installation_config).to receive(:value).and_return(nil)
|
allow(mock_installation_config).to receive(:value).and_return(nil)
|
||||||
|
|
||||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-mini')
|
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user