Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 1670567bb2 Merge branch 'develop' into chore/load-reply-message 2025-11-13 13:36:25 +05:30
Gabriel JablonskiandGitHub bdcb1934c0 feat(webhooks): add name to webhook (#12641)
## Description

When working with webhooks, it's easy to lose track of which URL is
which. Adding a `name` (optional) column to the webhook model is a
straight-forward solution to make it significantly easier to identify
webhooks.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Model and controller specs, and also running in production over several
months without any issues.

| Before | After |
| --- | --- |
| <img width="949" height="990" alt="image copy 3"
src="https://github.com/user-attachments/assets/6b33c072-7d16-4a9c-a129-f9c0751299f5"
/> | <img width="806" height="941" alt="image"
src="https://github.com/user-attachments/assets/77f3cb3a-2eb0-41ac-95bf-d02915589690"
/> |
| <img width="1231" height="650" alt="image copy 2"
src="https://github.com/user-attachments/assets/583374af-96e0-4436-b026-4ce79b7f9321"
/> | <img width="1252" height="650" alt="image copy"
src="https://github.com/user-attachments/assets/aa81fb31-fd18-4e21-a40e-d8ab0dc76b4e"
/> |


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2025-11-13 13:28:15 +05:30
Shivam MishraandGitHub 4f09c2203c feat: allow querying reporting events via the API (#12832) 2025-11-13 12:46:55 +05:30
f455e7994e fix: respect status parameter when creating articles via API (#12846)
## Description

The Articles API was ignoring the `status` parameter when creating new
articles. All articles were forced to be drafts due to a hardcoded
`@article.draft!` call in the controller, even when users explicitly
sent `status: 1` (published) in their API request.

This PR removes the hardcoded draft enforcement and allows the status
parameter to be respected while maintaining backward compatibility.

Fixes #12063

## Type of change

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

## How Has This Been Tested?

**Before:**
- API POST with `status: 1` → Created as draft (ignored parameter)
- API POST without status → Created as draft

**After:**
- API POST with `status: 1` → Created as published 
- API POST without status → Created as draft (backward compatible) 
- UI creates articles → Still creates as draft (UI doesn't send status)


**Tests run:**
```bash
bundle exec rspec spec/controllers/api/v1/accounts/articles_controller_spec.rb
# 17 examples, 0 failures
```

Updated tests:
1. Changed 2 existing tests that were verifying the broken behavior
(expecting draft when published was sent)
2. Added new test to verify articles default to draft when status is not
provided
3. All existing tests pass, confirming backward compatibility

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-11-13 12:07:24 +05:30
28e16f7ee0 feat: allow selecting month range in overview reports (#12701)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2025-11-12 18:34:00 +05:30
Sivin VargheseandGitHub fb0be60ae2 chore: Improve captain layout (#12820) 2025-11-12 18:31:17 +05:30
Tanmay Deep SharmaandGitHub 3c9c0298ba fix: label tags for contactable inboxes (#12838) 2025-11-12 18:31:09 +05:30
Vishnu NarayananandGitHub d6af182f82 fix: Use contact_id instead of sender_id for Instagram message locks (#12841)
Previously, the lock key for Instagram used sender_id, which for echo
messages (outgoing) would be the account's own ID. This caused all
outgoing messages to compete for the same lock, creating a bottleneck
during bulk messaging.

The fix introduces contact_instagram_id method that correctly identifies
the contact's ID regardless of message direction:
- For echo messages (outgoing): uses recipient.id (the contact)
- For incoming messages: uses sender.id (the contact)

This ensures each conversation has a unique lock, allowing parallel
processing of webhooks while maintaining race condition protection
within individual conversations.

Fixes lock acquisition errors in Sidekiq when processing bulk Instagram
messages.

Fixes
https://linear.app/chatwoot/issue/CW-5931/p0-mutexapplicationjoblockacquisitionerror-failed-to-acquire-lock-for

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2025-11-12 16:56:52 +05:30
Tanmay Deep SharmaandGitHub c07d03e4e5 fix: hide pdf citations in captain faq responses (#12839) 2025-11-11 21:21:24 +05:30
e81152608d fix: Issue with processing variables in outgoing email content (#12799)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-11-10 20:50:02 +05:30
615e81731c refactor: strategy pattern for mailbox conversation finding (#12766)
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-11-10 20:47:18 +05:30
Lê Nam KhánhandGitHub fb1aa085cf chore(docs): Fix typos in some files (#12817)
This PR fixes typos in the file file using codespell.
2025-11-07 07:57:37 -08:00
Chatwoot BotandGitHub e8a01ace41 chore: Update translations (#12794) 2025-11-07 10:21:33 +05:30
Sivin VargheseandGitHub 682a931707 Merge branch 'develop' into chore/load-reply-message 2025-11-07 10:15:43 +05:30
PranavandGitHub 01363042ce fix: Handle login when there are no accounts (#12816) 2025-11-07 10:14:59 +05:30
5bf39d20e5 feat: Update Captain navigation structure (#12761)
# Pull Request Template

## Description

This PR includes an update to the Captain navigation structure.

## Route Structure

```javascript
1. captain_assistants_responses_index    → /captain/:assistantId/faqs
2. captain_assistants_documents_index    → /captain/:assistantId/documents
3. captain_assistants_scenarios_index    → /captain/:assistantId/scenarios
4. captain_assistants_playground_index   → /captain/:assistantId/playground
5. captain_assistants_inboxes_index      → /captain/:assistantId/inboxes
6. captain_tools_index                   → /captain/tools
7. captain_assistants_settings_index     → /captain/:assistantId/settings
8. captain_assistants_guardrails_index   → /captain/:assistantId/settings/guardrails
9. captain_assistants_guidelines_index   → /captain/:assistantId/settings/guidelines
10. captain_assistants_index             → /captain/:navigationPath
```

**How it works:**

1. User clicks sidebar item → Routes to `captain_assistants_index` with
`navigationPath`
2. `AssistantsIndexPage` validates route and gets last active assistant,
if not redirects to assistant create page.
3. Routes to actual page: `/captain/:assistantId/:page`
4. Page loads with correct assistant context

Fixes
https://linear.app/chatwoot/issue/CW-5832/updating-captain-navigation

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?




## Checklist:

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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-11-06 16:31:23 -08:00
Tanmay Deep SharmaandGitHub 90352b3a20 fix: Remove the same account validation for whatsapp channels (#12811)
## Description

Modified the phone number validation in Whatsapp::ChannelCreationService
to check for duplicate phone numbers across ALL accounts, not just
within the current account.

## Type of change

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

## How Has This Been Tested?

- Added test coverage for cross-account phone number validation
- Using actual UI flow 
<img width="1493" height="532" alt="image"
src="https://github.com/user-attachments/assets/67d2bb99-2eb9-4115-8d56-449e4785e0d8"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-11-06 21:18:52 +05:30
Sivin VargheseandGitHub ba8df900e3 feat: Enhance button interactions (#12738) 2025-11-06 16:24:05 +05:30
Sivin VargheseandGitHub 7aa3adc4a3 Merge branch 'develop' into chore/load-reply-message 2025-11-06 14:21:18 +05:30
9b75d9bd1b fix: Add empty line before signature in compose conversation editor (#12702)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-11-06 14:05:52 +05:30
Shivam MishraandGitHub ec6c3b3571 feat: allow bots to handle campaigns when sender_id is nil (#12805) 2025-11-06 14:00:47 +05:30
Sivin VargheseandGitHub 48ba273730 fix: Invalid image URL issue in Help Center articles (#12806) 2025-11-06 13:53:31 +05:30
Sivin VargheseandGitHub 89576a6475 Merge branch 'develop' into chore/load-reply-message 2025-10-21 10:26:00 +05:30
Sivin VargheseandGitHub 307dbc4202 Merge branch 'develop' into chore/load-reply-message 2025-10-15 15:38:32 +05:30
Sivin VargheseandGitHub f34a20e12a Merge branch 'develop' into chore/load-reply-message 2025-10-07 19:18:25 +05:30
Sivin VargheseandGitHub 5e1680e840 Merge branch 'develop' into chore/load-reply-message 2025-10-06 10:00:58 +05:30
Sivin VargheseandGitHub 7bbedf2b6a Merge branch 'develop' into chore/load-reply-message 2025-10-05 20:08:38 +05:30
Sivin VargheseandGitHub a7d70f418b Merge branch 'develop' into chore/load-reply-message 2025-10-01 20:46:58 +05:30
Sivin VargheseandGitHub 38873fcfc2 Merge branch 'develop' into chore/load-reply-message 2025-09-30 21:28:13 +05:30
Muhsin KelothandGitHub 6cf8a6b7f8 Merge branch 'develop' into chore/load-reply-message 2025-09-29 11:13:13 +05:30
Sivin VargheseandGitHub 26ec0ebd09 Merge branch 'develop' into chore/load-reply-message 2025-09-26 11:51:54 +05:30
Sivin VargheseandGitHub d248f0bea2 Merge branch 'develop' into chore/load-reply-message 2025-09-25 16:59:28 +05:30
iamsivin 1caec05517 chore: Simplify reply message lookup with store-based fetching 2025-09-25 16:26:59 +05:30
199 changed files with 5926 additions and 2389 deletions
+32 -43
View File
@@ -1,5 +1,8 @@
class Messages::MessageBuilder
include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message
def initialize(user, conversation, params)
@@ -38,30 +41,12 @@ class Messages::MessageBuilder
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
return parse_json(content_attributes) if content_attributes.is_a?(String)
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments
return if @attachments.blank?
@@ -110,12 +95,6 @@ class Messages::MessageBuilder
email_string.gsub(/\s+/, '').split(',')
end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@@ -178,14 +157,17 @@ class Messages::MessageBuilder
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
# Process liquid templates in normalized content with code block protection
processed_content = process_liquid_in_email_body(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(normalized_content)
build_html_content(processed_content)
end
email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes[:text_content] = build_text_content(processed_content)
email_attributes
end
@@ -204,22 +186,6 @@ class Messages::MessageBuilder
text_content
end
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
def custom_email_content_provided?
@params[:email_html_content].present?
end
@@ -232,4 +198,27 @@ class Messages::MessageBuilder
html_content
end
# Liquid processing methods for email content
def process_liquid_in_email_body(content)
return content if content.blank?
return content unless should_process_liquid?
# Protect code blocks from liquid processing
modified_content = modified_liquid_content(content)
template = Liquid::Template.parse(modified_content)
template.render(drops_with_sender)
rescue Liquid::Error
content
end
def should_process_liquid?
@message_type == 'outgoing' || @message_type == 'template'
end
def drops_with_sender
message_drops(@conversation).merge({
'agent' => UserDrop.new(sender)
})
end
end
@@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def edit; end
def create
@article = @portal.articles.create!(article_params)
params_with_defaults = article_params
params_with_defaults[:status] ||= :draft
@article = @portal.articles.create!(params_with_defaults)
@article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private
def webhook_params
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
end
def fetch_webhook
+24
View File
@@ -0,0 +1,24 @@
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
# This module contains helper methods for converting between different data types,
# normalizing hashes, and safely handling JSON operations.
module DataHelper
# Ensures a hash supports indifferent access (string or symbol keys).
# Returns an empty hash if the input is blank.
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
def safe_parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
end
+21 -1
View File
@@ -4,6 +4,19 @@ module EmailHelper
domain.split('.').first
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
# Raise a standard error if any email address is invalid
def validate_email_addresses(emails_to_test)
emails_to_test&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
# This is not a mandatory requirement for email addresses, but it is a common practice.
# john+test@xyc.com is the same as john@xyc.com
@@ -21,6 +34,10 @@ module EmailHelper
end
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def modified_liquid_content(email)
# This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks
@@ -29,7 +46,10 @@ module EmailHelper
def message_drops(conversation)
{
'contact' => ContactDrop.new(conversation.contact)
'contact' => ContactDrop.new(conversation.contact),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(conversation.inbox),
'account' => AccountDrop.new(conversation.account)
}
end
end
-13
View File
@@ -1,6 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import AddAccountModal from './components/app/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
@@ -25,7 +24,6 @@ export default {
name: 'App',
components: {
AddAccountModal,
LoadingState,
NetworkNotification,
UpdateBanner,
@@ -51,7 +49,6 @@ export default {
},
data() {
return {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
};
@@ -64,21 +61,12 @@ export default {
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
}),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
},
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
},
},
watch: {
currentUser() {
if (!this.hasAccounts) {
this.showAddAccountModal = true;
}
},
currentAccountId: {
immediate: true,
handler() {
@@ -156,7 +144,6 @@ export default {
<component :is="Component" />
</transition>
</router-view>
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox />
<NetworkNotification />
</div>
@@ -1,5 +1,6 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -55,17 +56,17 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
<ButtonGroup
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost
slate
sm
class="!rounded-full"
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
:class="{
'bg-n-alpha-2': isContactSidebarOpen,
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
}"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
@@ -75,13 +76,14 @@ useKeyboardEvents(keyboardEvents);
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost
slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
:class="{
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm':
isCopilotPanelOpen,
}"
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</ButtonGroup>
</template>
@@ -21,6 +21,10 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -100,6 +104,10 @@ watch(
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -14,6 +14,7 @@ import {
} from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => {
:disabled="!articleId"
@click="previewArticle"
/>
<div class="flex items-center">
<ButtonGroup class="flex items-center">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
@@ -159,6 +161,7 @@ const updateArticleStatus = async ({ value }) => {
icon="i-lucide-chevron-down"
size="sm"
:disabled="!articleId"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none"
@click.stop="showArticleActionMenu = !showArticleActionMenu"
/>
@@ -170,7 +173,7 @@ const updateArticleStatus = async ({ value }) => {
/>
</OnClickOutside>
</div>
</div>
</ButtonGroup>
</div>
</div>
</div>
@@ -92,7 +92,6 @@ const setSignature = () => {
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
};
// Added this watch to dynamically set signature on target inbox change.
@@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => {
state.attachedFiles = [];
};
const removeTargetInbox = value => {
v$.value.$reset();
// Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -216,6 +220,7 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
removeSignatureFromMessage();
};
const onClickInsertEmoji = emoji => {
@@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => {
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
/>
<AttachmentPreviews
@@ -17,6 +17,7 @@ const props = defineProps({
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
});
const { t } = useI18n();
@@ -90,6 +91,10 @@ const replaceText = async message => {
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
@@ -36,6 +36,7 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
noAnimation: { type: Boolean, default: false },
});
const slots = useSlots();
@@ -179,12 +180,18 @@ const STYLE_CONFIG = {
md: 'text-sm font-medium',
lg: 'text-base',
},
clickAnimation: {
xs: 'active:enabled:scale-[0.97]',
sm: 'active:enabled:scale-[0.97]',
md: 'active:enabled:scale-[0.98]',
lg: 'active:enabled:scale-[0.98]',
},
justify: {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
},
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
};
const variantClasses = computed(() => {
@@ -221,6 +228,12 @@ const linkButtonClasses = computed(() => {
return classes.join(' ');
});
const animationClasses = computed(() => {
return props.noAnimation
? ''
: STYLE_CONFIG.clickAnimation[computedSize.value];
});
</script>
<template>
@@ -230,6 +243,7 @@ const linkButtonClasses = computed(() => {
[STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[computedSize]]: true,
[animationClasses]: true,
[STYLE_CONFIG.justify[computedJustify]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly,
}"
@@ -0,0 +1,20 @@
<script setup>
defineProps({
noAnimation: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div
:class="
noAnimation
? ''
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
"
>
<slot />
</div>
</template>
@@ -1,11 +1,16 @@
<script setup>
import { computed } from 'vue';
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue';
import AssistantSwitcher from 'dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
const props = defineProps({
currentPage: {
@@ -56,11 +61,32 @@ const props = defineProps({
type: Boolean,
default: true,
},
showAssistantSwitcher: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['click', 'close', 'update:currentPage']);
const route = useRoute();
const { shouldShowPaywall } = usePolicy();
const showAssistantSwitcherDropdown = ref(false);
const createAssistantDialogRef = ref(null);
const assistants = useMapGetter('captainAssistants/getRecords');
const currentAssistantId = computed(() => route.params.assistantId);
const activeAssistantName = computed(() => {
return (
assistants.value?.find(
assistant => assistant.id === Number(currentAssistantId.value)
)?.name || ''
);
});
const showPaywall = computed(() => {
return shouldShowPaywall(props.featureFlag);
});
@@ -72,6 +98,15 @@ const handleButtonClick = () => {
const handlePageChange = event => {
emit('update:currentPage', event);
};
const toggleAssistantSwitcher = () => {
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
};
const handleCreateAssistant = () => {
showAssistantSwitcherDropdown.value = false;
createAssistantDialogRef.value.dialogRef.open();
};
</script>
<template>
@@ -81,37 +116,78 @@ const handlePageChange = event => {
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
<div class="flex gap-4 items-center">
<BackButton v-if="backUrl" :to="backUrl" />
<slot name="headerTitle">
<span class="text-xl font-medium text-n-slate-12">
<div class="flex gap-3 items-center">
<BackButton v-if="backUrl" :back-url="backUrl" />
<div v-if="showAssistantSwitcher" class="flex items-center gap-2">
<div class="flex items-center gap-2">
<span
v-if="activeAssistantName"
class="text-xl font-medium truncate text-n-slate-12"
>
{{ activeAssistantName }}
</span>
<div v-if="activeAssistantName" class="relative group">
<OnClickOutside
@trigger="showAssistantSwitcherDropdown = false"
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="toggleAssistantSwitcher"
/>
<AssistantSwitcher
v-if="showAssistantSwitcherDropdown"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showAssistantSwitcherDropdown = false"
@create-assistant="handleCreateAssistant"
/>
</OnClickOutside>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div
v-if="showAssistantSwitcher && headerTitle"
class="w-0.5 h-4 rounded-2xl bg-n-weak"
/>
<span
v-if="headerTitle"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
</slot>
<div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
<div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
</div>
</div>
</div>
<div
v-if="!showPaywall && buttonLabel"
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Policy :permissions="buttonPolicy">
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
</Policy>
<slot name="action" />
<div class="flex gap-2">
<slot name="search" />
<div
v-if="!showPaywall && buttonLabel"
v-on-clickaway="() => emit('close')"
class="relative group/captain-button"
>
<Policy :permissions="buttonPolicy">
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/captain-button:brightness-110"
@click="handleButtonClick"
/>
</Policy>
<slot name="action" />
</div>
</div>
</div>
<slot name="subHeader" />
@@ -144,5 +220,6 @@ const handlePageChange = event => {
@update:current-page="handlePageChange"
/>
</footer>
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
</section>
</template>
@@ -1,91 +0,0 @@
<script setup>
import { useRouter } from 'vue-router';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showPaginationFooter: {
type: Boolean,
default: false,
},
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:currentPage']);
const router = useRouter();
const handlePageChange = event => {
emit('update:currentPage', event);
};
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
</script>
<template>
<section
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
<section
v-if="$slots.body || $slots.emptyState || isFetching"
class="flex flex-col w-full"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
</section>
<section v-if="$slots.controls" class="flex w-full">
<slot name="controls" />
</section>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</div>
</section>
</template>
@@ -76,12 +76,11 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<router-link
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
<h6
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }}
</router-link>
</h6>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MessageList from './MessageList.vue';
@@ -29,6 +29,16 @@ const resetConversation = () => {
newMessage.value = '';
};
// Watch for assistant ID changes and reset conversation
watch(
() => assistantId,
(newId, oldId) => {
if (oldId && newId !== oldId) {
resetConversation();
}
}
);
const sendMessage = async () => {
if (!newMessage.value.trim() || isLoading.value) return;
@@ -65,16 +75,17 @@ const sendMessage = async () => {
<template>
<div
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
>
<div class="mb-4">
<div class="mb-8 px-6">
<div class="flex justify-between items-center mb-1">
<h3 class="text-lg font-medium">
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
</h3>
<NextButton
ghost
size="small"
sm
slate
icon="i-lucide-rotate-ccw"
@click="resetConversation"
/>
@@ -87,17 +98,17 @@ const sendMessage = async () => {
<MessageList :messages="messages" :is-loading="isLoading" />
<div
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
>
<input
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
/>
<NextButton
ghost
size="small"
sm
:disabled="!newMessage.trim()"
icon="i-lucide-send"
@click="sendMessage"
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
const getMessageStyle = sender =>
isUserMessage(sender)
? 'bg-n-strong text-n-white'
: 'bg-n-solid-iris text-n-slate-12';
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
const scrollToBottom = async () => {
await nextTick();
@@ -49,7 +49,10 @@ watch(() => props.messages.length, scrollToBottom);
</script>
<template>
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
<div
ref="messageContainer"
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
>
<div
v-for="(message, index) in messages"
:key="index"
@@ -57,15 +60,20 @@ watch(() => props.messages.length, scrollToBottom);
:class="getMessageAlignment(message.sender)"
>
<div
class="flex items-start gap-1.5"
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
:class="getMessageDirection(message.sender)"
>
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
<Avatar
:name="getAvatarName(message.sender)"
rounded-full
:size="24"
class="shrink-0"
/>
<div
class="max-w-[80%] rounded-lg p-3 text-sm"
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
:class="getMessageStyle(message.sender)"
>
<div class="break-words" v-html="formatMessage(message.content)" />
<div v-html="formatMessage(message.content)" />
</div>
</div>
</div>
@@ -1,68 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
assistantId: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const isFilterOpen = ref(false);
const assistants = useMapGetter('captainAssistants/getRecords');
const assistantOptions = computed(() => [
{
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
value: 'all',
action: 'filter',
},
...assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
action: 'filter',
})),
]);
const selectedAssistantLabel = computed(() => {
const assistant = assistantOptions.value.find(
option => option.value === props.assistantId
);
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
selected: assistant ? assistant.label : '',
});
});
const handleAssistantFilterChange = ({ value }) => {
isFilterOpen.value = false;
emit('update', value);
};
</script>
<template>
<OnClickOutside @trigger="isFilterOpen = false">
<Button
:label="selectedAssistantLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isFilterOpen = !isFilterOpen"
/>
<DropdownMenu
v-if="isFilterOpen"
:menu-items="assistantOptions"
class="mt-2"
@action="handleAssistantFilterChange"
/>
</OnClickOutside>
</template>
@@ -18,7 +18,7 @@ const props = defineProps({
validator: value => ['create', 'edit'].includes(value),
},
});
const emit = defineEmits(['close']);
const emit = defineEmits(['close', 'created']);
const { t } = useI18n();
const store = useStore();
@@ -35,8 +35,18 @@ const i18nKey = computed(
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
);
const createAssistant = assistantDetails =>
store.dispatch('captainAssistants/create', assistantDetails);
const createAssistant = async assistantDetails => {
try {
const newAssistant = await store.dispatch(
'captainAssistants/create',
assistantDetails
);
emit('created', newAssistant);
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleSubmit = async updatedAssistant => {
try {
@@ -1,333 +0,0 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
};
const initialState = {
name: '',
description: '',
productName: '',
welcomeMessage: '',
handoffMessage: '',
resolutionMessage: '',
instructions: '',
features: {
conversationFaqs: false,
memories: false,
citations: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
welcomeMessage: { minLength: minLength(1) },
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
welcomeMessage: getErrorMessage('welcomeMessage'),
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.welcomeMessage = config.welcome_message;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
config: {
...props.assistant.config,
product_name: state.productName,
},
};
emit('submit', payload);
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.welcomeMessage.$validate(),
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
welcome_message: state.welcomeMessage,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
},
};
emit('submit', payload);
};
const handleInstructionsUpdate = async () => {
const result = await v$.value.instructions.$validate();
if (!result) return;
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
emit('submit', payload);
};
const handleFeaturesUpdate = () => {
const payload = {
config: {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (props.mode === 'edit' && newAssistant) {
updateStateFromAssistant(newAssistant);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Basic Information Section -->
<Accordion
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
is-open
>
<div class="flex flex-col gap-4 pt-4">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleBasicInfoUpdate"
>
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleInstructionsUpdate"
/>
</div>
</div>
</Accordion>
<!-- Greeting Messages Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
<div class="flex flex-col gap-4 pt-4">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</Accordion>
<!-- Features Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input
v-model="state.features.conversationFaqs"
type="checkbox"
/>
{{
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
}}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.memories" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
</div>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleFeaturesUpdate"
/>
</div>
</div>
</Accordion>
</form>
</template>
@@ -3,6 +3,8 @@ import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { minLength } from '@vuelidate/validators';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -17,10 +19,16 @@ const props = defineProps({
const emit = defineEmits(['submit']);
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const isCaptainV2Enabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const initialState = {
handoffMessage: '',
resolutionMessage: '',
instructions: '',
temperature: 1,
};
@@ -29,6 +37,7 @@ const state = reactive({ ...initialState });
const validationRules = {
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
@@ -40,20 +49,30 @@ const getErrorMessage = field => {
const formErrors = computed(() => ({
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
const validations = [
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
];
if (!isCaptainV2Enabled.value) {
validations.push(v$.value.instructions.$validate());
}
const result = await Promise.all(validations).then(results =>
results.every(Boolean)
);
if (!result) return;
const payload = {
@@ -65,6 +84,10 @@ const handleSystemMessagesUpdate = async () => {
},
};
if (!isCaptainV2Enabled.value) {
payload.config.instructions = state.instructions;
}
emit('submit', payload);
};
@@ -95,6 +118,16 @@ watch(
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<Editor
v-if="!isCaptainV2Enabled"
v-model="state.instructions"
:label="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
@@ -8,6 +8,13 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue';
defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
@@ -48,7 +55,11 @@ defineExpose({ dialogRef });
:show-confirm-button="false"
@close="handleClose"
>
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
<DocumentForm
:assistant-id="assistantId"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>
@@ -2,7 +2,7 @@
import { reactive, computed, ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, requiredIf, url } from '@vuelidate/validators';
import { minLength, requiredIf, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
@@ -10,6 +10,13 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['submit', 'cancel']);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -18,13 +25,11 @@ const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
name: '',
url: '',
assistantId: null,
documentType: 'url',
pdfFile: null,
};
@@ -38,19 +43,11 @@ const validationRules = {
url: requiredIf(() => state.documentType === 'url' && url),
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
},
assistantId: { required },
pdfFile: {
required: requiredIf(() => state.documentType === 'pdf'),
},
};
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const documentTypeOptions = [
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
@@ -70,7 +67,6 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
}));
@@ -106,7 +102,7 @@ const openFileDialog = () => {
const prepareDocumentDetails = () => {
const formData = new FormData();
formData.append('document[assistant_id]', state.assistantId);
formData.append('document[assistant_id]', props.assistantId);
if (state.documentType === 'url') {
formData.append('document[external_link]', state.url);
@@ -218,21 +214,6 @@ const handleSubmit = async () => {
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2"
:message="formErrors.assistantId"
/>
</div>
<div class="flex gap-3 justify-between items-center w-full">
<Button
type="button"
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseForm from './ResponseForm.vue';
@@ -21,6 +22,7 @@ const props = defineProps({
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
const responseForm = ref(null);
@@ -39,9 +41,15 @@ const createResponse = responseDetails =>
const handleSubmit = async updatedResponse => {
try {
if (props.type === 'edit') {
await updateResponse(updatedResponse);
await updateResponse({
...updatedResponse,
assistant_id: route.params.assistantId,
});
} else {
await createResponse(updatedResponse);
await createResponse({
...updatedResponse,
assistant_id: route.params.assistantId,
});
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
@@ -8,7 +8,6 @@ import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
mode: {
@@ -21,18 +20,17 @@ const props = defineProps({
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainResponses/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
question: '',
answer: '',
assistantId: null,
};
const state = reactive({ ...initialState });
@@ -40,16 +38,8 @@ const state = reactive({ ...initialState });
const validationRules = {
question: { required, minLength: minLength(1) },
answer: { required, minLength: minLength(1) },
assistantId: { required },
};
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
@@ -63,7 +53,6 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({
question: getErrorMessage('question', 'QUESTION'),
answer: getErrorMessage('answer', 'ANSWER'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
}));
const handleCancel = () => emit('cancel');
@@ -71,7 +60,6 @@ const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
question: state.question,
answer: state.answer,
assistant_id: state.assistantId,
});
const handleSubmit = async () => {
@@ -86,12 +74,11 @@ const handleSubmit = async () => {
const updateStateFromResponse = response => {
if (!response) return;
const { question, answer, assistant } = response;
const { question, answer } = response;
Object.assign(state, {
question,
answer,
assistantId: assistant.id,
});
};
@@ -115,7 +102,6 @@ watch(
:message="formErrors.question"
:message-type="formErrors.question ? 'error' : 'info'"
/>
<Editor
v-model="state.answer"
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
@@ -124,22 +110,6 @@ watch(
:max-length="10000"
:message-type="formErrors.answer ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
@@ -0,0 +1,143 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const emit = defineEmits(['close', 'createAssistant']);
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const assistants = useMapGetter('captainAssistants/getRecords');
const currentAssistantId = computed(() => route.params.assistantId);
const isAssistantActive = assistant => {
return assistant.id === Number(currentAssistantId.value);
};
const fetchDataForRoute = async (routeName, assistantId) => {
const dataFetchMap = {
captain_assistants_responses_index: async () => {
await store.dispatch('captainResponses/get', { assistantId });
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
},
captain_assistants_responses_pending: async () => {
await store.dispatch('captainResponses/get', {
assistantId,
status: 'pending',
});
},
captain_assistants_documents_index: async () => {
await store.dispatch('captainDocuments/get', { assistantId });
},
captain_assistants_scenarios_index: async () => {
await store.dispatch('captainScenarios/get', { assistantId });
},
captain_assistants_playground_index: () => {
// Playground doesn't need pre-fetching, it loads on interaction
},
captain_assistants_inboxes_index: async () => {
await store.dispatch('captainInboxes/get', { assistantId });
},
captain_tools_index: async () => {
await store.dispatch('captainCustomTools/get', { page: 1 });
},
captain_assistants_settings_index: async () => {
await store.dispatch('captainAssistants/show', assistantId);
},
};
const fetchFn = dataFetchMap[routeName];
if (fetchFn) {
await fetchFn();
}
};
const handleAssistantChange = async assistant => {
if (isAssistantActive(assistant)) return;
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_responses_index';
await fetchDataForRoute(targetRouteName, assistant.id);
await router.push({
name: targetRouteName,
params: {
accountId: route.params.accountId,
assistantId: assistant.id,
},
});
emit('close');
};
const openCreateAssistantDialog = () => {
emit('createAssistant');
emit('close');
};
</script>
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ASSISTANTS') }}
</h2>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p>
</div>
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button
v-for="assistant in assistants"
:key="assistant.id"
:label="assistant.name"
variant="ghost"
color="slate"
trailing-icon
:icon="isAssistantActive(assistant) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
size="sm"
@click="handleAssistantChange(assistant)"
>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ assistant.name || '' }}
</span>
<Avatar
v-if="assistant"
:name="assistant.name"
:size="20"
icon-name="i-lucide-bot"
rounded-full
/>
</Button>
</div>
</div>
</template>
@@ -96,6 +96,7 @@ watch(
:label="selectedLabel"
trailing-icon
:disabled="disabled"
no-animation
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
:class="{
focused: open,
@@ -78,8 +78,10 @@ const handleSuggestion = opt => {
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
name: 'captain_assistants_create_index',
params: {
accountId: route.params.accountId,
},
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -53,14 +54,17 @@ const toggleSidebar = () => {
v-if="showCopilotLauncher"
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
>
<div class="rounded-full bg-n-alpha-2 p-1">
<ButtonGroup
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
no-animation
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg
@click="toggleSidebar"
/>
</div>
</ButtonGroup>
</div>
<template v-else />
</template>
@@ -8,11 +8,15 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
menuItems: {
type: Array,
required: true,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
menuSections: {
type: Array,
default: () => [],
},
thumbnailSize: {
type: Number,
default: 20,
@@ -42,19 +46,62 @@ const { t } = useI18n();
const searchInput = ref(null);
const searchQuery = ref('');
const filteredMenuItems = computed(() => {
if (!searchQuery.value) return props.menuItems;
const hasSections = computed(() => props.menuSections.length > 0);
return props.menuItems.filter(item =>
const flattenedMenuItems = computed(() => {
if (!hasSections.value) {
return props.menuItems;
}
return props.menuSections.flatMap(section => section.items || []);
});
const filteredMenuItems = computed(() => {
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const filteredMenuSections = computed(() => {
if (!hasSections.value) {
return [];
}
if (!searchQuery.value) {
return props.menuSections;
}
const query = searchQuery.value.toLowerCase();
return props.menuSections
.map(section => {
const filteredItems = (section.items || []).filter(item =>
item.label.toLowerCase().includes(query)
);
return {
...section,
items: filteredItems,
};
})
.filter(section => section.items.length > 0);
});
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
};
const shouldShowEmptyState = computed(() => {
if (hasSections.value) {
return filteredMenuSections.value.length === 0;
}
return filteredMenuItems.value.length === 0;
});
onMounted(() => {
if (searchInput.value && props.showSearch) {
searchInput.value.focus();
@@ -64,54 +111,122 @@ onMounted(() => {
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
>
<div v-if="showSearch" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="filteredMenuItems.length === 0"
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
</div>
<template v-if="hasSections">
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
>
{{ section.title }}
</p>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{
@@ -1,10 +1,9 @@
<script setup>
import { defineProps, computed, reactive } from 'vue';
import { defineProps, computed, ref } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMapGetter } from 'dashboard/composables/store.js';
import MessageApi from 'dashboard/api/inbox/message.js';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
/**
* Props definition for the component
@@ -40,53 +39,15 @@ const props = defineProps({
});
const emit = defineEmits(['retry']);
const store = useStore();
const fetchingConversations = ref(new Set());
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
/**
* Fetches a specific message from the API by trying to get messages around it
* @param {number} messageId - The ID of the message to fetch
* @param {number} conversationId - The ID of the conversation
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
*/
const fetchReplyMessage = async (messageId, conversationId) => {
// Return cached result if already fetched
if (fetchedReplyMessages.has(messageId)) {
return fetchedReplyMessages.get(messageId);
}
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: messageId + 100,
after: messageId - 100,
});
const messages = response.data?.payload || [];
const targetMessage = messages.find(msg => msg.id === messageId);
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
return null;
}
};
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
@@ -126,36 +87,43 @@ const shouldGroupWithNext = (index, searchList) => {
* @returns {Object|null} - The message being replied to, or null if not found
*/
const getInReplyToMessage = parentMessage => {
if (!parentMessage) return null;
const inReplyToMessageId =
parentMessage.contentAttributes?.inReplyTo ??
parentMessage.content_attributes?.in_reply_to;
parentMessage?.contentAttributes?.inReplyTo ??
parentMessage?.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return null;
// Try to find in current messages first
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
// 1. Check props messages (already camelCased via allMessages)
const foundInProps = allMessages.value?.find(
m => m.id === inReplyToMessageId
);
if (foundInProps) return foundInProps;
// Then try store messages
if (!replyMessage && currentChat.value?.messages) {
replyMessage = currentChat.value.messages.find(
msg => msg.id === inReplyToMessageId
);
// 2. Check store messages
const foundInStore = currentChat.value?.messages?.find(
m => m.id === inReplyToMessageId
);
if (foundInStore) return useCamelCase(foundInStore);
// 3. Fetch if not currently fetching for this conversation
const conversationId = currentChat.value?.id;
if (
conversationId &&
!currentChat.value.allMessagesLoaded &&
!fetchingConversations.value.has(conversationId)
) {
fetchingConversations.value.add(conversationId);
store
.dispatch('fetchPreviousMessages', {
conversationId,
before: currentChat.value.messages?.[0]?.id ?? null,
})
.finally(() => {
fetchingConversations.value.delete(conversationId);
});
}
// Then check fetch cache
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
}
// If still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
return null; // Let UI handle loading state
}
return replyMessage ? useCamelCase(replyMessage) : null;
return null;
};
</script>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
defineProps({
isMobileSidebarOpen: {
@@ -45,14 +46,17 @@ const toggleSidebar = () => {
},
]"
>
<div class="rounded-full bg-n-alpha-2 p-1">
<ButtonGroup
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button
icon="i-lucide-menu"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
no-animation
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg
@click="toggleSidebar"
/>
</div>
</ButtonGroup>
</div>
<template v-else />
</template>
@@ -221,26 +221,70 @@ const menuItems = computed(() => {
name: 'Captain',
icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [
{
name: 'Assistants',
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
to: accountScopedRoute('captain_assistants_index'),
name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
activeOn: [
'captain_assistants_responses_index',
'captain_assistants_responses_pending',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_responses_index',
}),
},
{
name: 'Documents',
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
to: accountScopedRoute('captain_documents_index'),
activeOn: ['captain_assistants_documents_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_documents_index',
}),
},
{
name: 'Responses',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
to: accountScopedRoute('captain_responses_index'),
name: 'Scenarios',
label: t('SIDEBAR.CAPTAIN_SCENARIOS'),
activeOn: ['captain_assistants_scenarios_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_scenarios_index',
}),
},
{
name: 'Playground',
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
activeOn: ['captain_assistants_playground_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_playground_index',
}),
},
{
name: 'Inboxes',
label: t('SIDEBAR.CAPTAIN_INBOXES'),
activeOn: ['captain_assistants_inboxes_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_inboxes_index',
}),
},
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
to: accountScopedRoute('captain_tools_index'),
activeOn: ['captain_tools_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_tools_index',
}),
},
{
name: 'Settings',
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
activeOn: [
'captain_assistants_settings_index',
'captain_assistants_guidelines_index',
'captain_assistants_guardrails_index',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_settings_index',
}),
},
],
},
@@ -19,19 +19,46 @@ export function useSidebarContext() {
return '/';
};
// Helper to find route definition by name without resolving
const findRouteByName = name => {
const routes = router.getRoutes();
return routes.find(route => route.name === name);
};
const resolvePermissions = to => {
if (to) return router.resolve(to)?.meta?.permissions ?? [];
return [];
if (!to) return [];
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.permissions ?? [];
}
return router.resolve(to)?.meta?.permissions ?? [];
};
const resolveFeatureFlag = to => {
if (to) return router.resolve(to)?.meta?.featureFlag || '';
return '';
if (!to) return '';
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.featureFlag || '';
}
return router.resolve(to)?.meta?.featureFlag || '';
};
const resolveInstallationType = to => {
if (to) return router.resolve(to)?.meta?.installationTypes || [];
return [];
if (!to) return [];
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.installationTypes || [];
}
return router.resolve(to)?.meta?.installationTypes || [];
};
const isAllowed = to => {
@@ -1,5 +1,7 @@
<script setup>
import { computed } from 'vue';
import { computed, ref, onMounted, nextTick } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({
initialActiveTab: {
type: Number,
@@ -22,6 +24,32 @@ const emit = defineEmits(['tabChanged']);
const activeTab = computed(() => props.initialActiveTab);
const tabRefs = ref([]);
const indicatorStyle = ref({});
const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
};
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
});
const selectTab = index => {
emit('tabChanged', props.tabs[index]);
};
@@ -37,20 +65,30 @@ const showDivider = index => {
</script>
<template>
<div class="flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit">
<div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
:class="{ 'transition-all duration-300 ease-out': enableTransition }"
:style="indicatorStyle"
/>
<template v-for="(tab, index) in tabs" :key="index">
<button
class="relative px-4 truncate py-1.5 text-sm border-0 outline-1 outline rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand"
:ref="el => (tabRefs[index] = el)"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
? 'text-n-blue-text bg-n-solid-active outline-n-container dark:outline-transparent'
: 'text-n-slate-10 outline-transparent h-8',
? 'text-n-blue-text scale-100'
: 'text-n-slate-10 scale-[0.98]',
]"
@click="selectTab(index)"
>
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
</button>
<div
v-if="index < tabs.length - 1"
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
:class="
showDivider(index)
@@ -15,6 +15,7 @@ import {
CMD_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const store = useStore();
@@ -133,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div
<ButtonGroup
class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
@@ -142,6 +143,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
size="sm"
color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading"
@click="onCmdResolveConversation"
@@ -151,6 +153,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
size="sm"
color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading"
@click="onCmdOpenConversation"
@@ -160,6 +163,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.OPEN_ACTION')"
size="sm"
color="slate"
no-animation
:is-loading="isLoading"
@click="onCmdOpenConversation"
/>
@@ -169,12 +173,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
icon="i-lucide-chevron-down"
:disabled="isLoading"
size="sm"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
color="slate"
trailing-icon
@click="openDropdown"
/>
</div>
</ButtonGroup>
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
@@ -302,7 +302,16 @@ function isBodyEmpty(content) {
}
function handleEmptyBodyWithSignature() {
const { schema, tr } = state;
const { schema, tr, doc } = state;
const isEmptyParagraph = node =>
node && node.type === schema.nodes.paragraph && node.content.size === 0;
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
if (isEmptyParagraph(doc.firstChild)) {
focusEditorInputField('start');
return;
}
// create a paragraph node and
// start a transaction to append it at the end
@@ -59,7 +59,7 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled"
:class="{
'cursor-not-allowed': disabled,
@@ -14,7 +14,7 @@ import {
} from 'dashboard/constants/automation';
/**
* This is a shared composables that holds utilites used to build dropdown and file options
* This is a shared composables that holds utilities used to build dropdown and file options
* @returns {Object} An object containing various automation-related functions and computed properties.
*/
export default function useAutomationValues() {
@@ -21,7 +21,7 @@ export const initializeAudioAlerts = user => {
enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone,
// UI Settings can be undefined initally as we don't send the
// UI Settings can be undefined initially as we don't send the
// entire payload for the user during the signup process.
} = uiSettings || {};
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "حذف",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "حذف جهة الاتصال"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Изтрий",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Изтриване на контакта"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Esborrar",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Contacte esborrat"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Vymazat",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Slet",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Slet kontakt"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Löschen",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Kontakt löschen"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Διαγραφή",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Διαγραφή Επαφής"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -46,6 +46,10 @@
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
}
},
"NAME": {
"LABEL": "Webhook Name",
"PLACEHOLDER": "Enter the name of the webhook"
},
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: {webhookExampleURL}",
@@ -341,6 +345,11 @@
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant"
},
"COPILOT": {
"TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
@@ -480,9 +489,7 @@
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"HEADER": "Settings",
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
@@ -499,15 +506,16 @@
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistants replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
},
"DELETE": {
"TITLE": "Delete Assistant",
"DESCRIPTION": "This action is permanent. Deleting this assistant will remove it from all connected inboxes and permanently erase all generated knowledge.",
"BUTTON_TEXT": "Delete {assistantName}"
}
},
"OPTIONS": {
@@ -526,9 +534,6 @@
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
"BREADCRUMB": {
"TITLE": "Guardrails"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
@@ -574,9 +579,6 @@
"RESPONSE_GUIDELINES": {
"TITLE": "Response Guidelines",
"DESCRIPTION": "The vibe and structure of your assistants replies—clear and friendly? Short and snappy? Detailed and formal?",
"BREADCRUMB": {
"TITLE": "Response Guidelines"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
@@ -622,9 +624,6 @@
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
"BREADCRUMB": {
"TITLE": "Scenarios"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
@@ -722,11 +721,6 @@
"NAME": {
"LABEL": "Document Name (Optional)",
"PLACEHOLDER": "Enter a name for the document"
},
"ASSISTANT": {
"LABEL": "Assistant",
"PLACEHOLDER": "Select the assistant",
"ERROR": "The assistant field is required"
}
},
"DELETE": {
@@ -921,12 +915,6 @@
"LABEL": "Answer",
"PLACEHOLDER": "Enter the answer here",
"ERROR": "Please provide a valid answer."
},
"ASSISTANT": {
"LABEL": "Assistant",
"PLACEHOLDER": "Select an assistant",
"ERROR": "Please select an assistant."
}
},
"EDIT": {
@@ -53,6 +53,8 @@
"LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
@@ -251,6 +251,12 @@
"ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended",
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
},
"NO_ACCOUNTS": {
"TITLE": "No account found",
"MESSAGE_CLOUD": "You are not part of any accounts right now. If you think this is a mistake, please reach out to our support team.",
"MESSAGE_SELF_HOSTED": "You are not part of any accounts right now. Please reach out to your administrator.",
"LOGOUT": "Log out"
}
},
"COMPONENTS": {
@@ -305,6 +311,10 @@
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"CAPTAIN_SCENARIOS": "Scenarios",
"CAPTAIN_PLAYGROUND": "Playground",
"CAPTAIN_INBOXES": "Inboxes",
"CAPTAIN_SETTINGS": "Settings",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Eliminar",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Eliminar contacto"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "حذف",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "حذف مخاطب"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Poista",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Poista yhteystieto"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Supprimer",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Supprimer le contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "מחק",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "מחק איש קשר"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Izbriši",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Törlés",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Kontakt törlése"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Hapus",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Hapus kontak"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Eyða",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Eyða tengilið"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -13,7 +13,7 @@
"BROWSER": "Browser",
"OS": "Sistema Operativo",
"INITIATED_FROM": "Iniziato da",
"INITIATED_AT": "Iniziato alle",
"INITIATED_AT": "Avviata alle",
"IP_ADDRESS": "Indirizzo IP",
"CREATED_AT_LABEL": "Creato il",
"NEW_MESSAGE": "Nuovo messaggio",
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "Nessuna etichetta disponibile.",
"SELECTED_COUNT": "{count} selezionate",
"CLEAR_SELECTION": "Annulla selezione",
"SELECT_ALL": "Seleziona tutto ({count})"
"SELECT_ALL": "Seleziona tutto ({count})",
"DELETE_CONTACTS": "Elimina",
"DELETE_SUCCESS": "Contatti eliminati con successo.",
"DELETE_FAILED": "Impossibile eliminare i contatti.",
"DELETE_DIALOG": {
"TITLE": "Elimina i contatti selezionati",
"SINGULAR_TITLE": "Elimina il contatto selezionato",
"DESCRIPTION": "Questo eliminerà definitivamente i {count} contatti selezionati. Questa azione non può essere annullata.",
"SINGULAR_DESCRIPTION": "Questo eliminerà definitivamente il contatto selezionato. Questa azione non può essere annullata.",
"CONFIRM_MULTIPLE": "Elimina contatti",
"CONFIRM_SINGLE": "Elimina contatto"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -1,6 +1,6 @@
{
"CONVERSATION": {
"SELECT_A_CONVERSATION": "Si prega di selezionare una conversazione dal pannello a sinistra",
"SELECT_A_CONVERSATION": "Seleziona una conversazione dal pannello a sinistra",
"CSAT_REPLY_MESSAGE": "Valuta la conversazione",
"404": "Siamo spiacenti, non siamo riusciti a trovare la conversazione. Riprova",
"SWITCH_VIEW_LAYOUT": "Cambia layout",
@@ -6,7 +6,7 @@
"LOADING": "Caricamento notifiche",
"404": "Non ci sono notifiche attive in questo gruppo.",
"NO_NOTIFICATIONS": "Nessuna notifica",
"NOTE": "Notifiche da tutte le inbox sottoscritte",
"NOTE": "Notifiche da tutte le Inbox",
"NO_MESSAGES_AVAILABLE": "Oops! Impossibile recuperare i messaggi",
"SNOOZED_UNTIL": "Posticipata fino a",
"SNOOZED_UNTIL_TOMORROW": "Posticipata fino a domani",
@@ -45,8 +45,8 @@
"MARK_AS_UNREAD": "Segna come da leggere",
"SNOOZE": "Posticipa",
"DELETE": "Elimina",
"MARK_ALL_READ": "Segna tutto come letto",
"DELETE_ALL": "Elimina tutto",
"MARK_ALL_READ": "Segna tutte come lette",
"DELETE_ALL": "Elimina tutte",
"DELETE_ALL_READ": "Elimina tutte le lette"
},
"DISPLAY_MENU": {
@@ -252,7 +252,7 @@
},
"BUSINESS_ACCOUNT_ID": {
"LABEL": "ID Account Business",
"PLACEHOLDER": "Si prega di inserire l'ID dell'account business ottenuto dalla dashboard sviluppatore di Facebook.",
"PLACEHOLDER": "Inserisci l'ID dell'Account Business ottenuto dalla dashboard sviluppatore di Facebook.",
"ERROR": "Inserisci un valore valido."
},
"WEBHOOK_VERIFY_TOKEN": {
@@ -107,7 +107,7 @@
},
"HELP_TEXT": {
"TITLE": "Stai utilizzando l'integrazione Slack",
"BODY": "Con questa integrazione, tutte le conversazioni in arrivo verranno sincronizzate nel canale ***{selectedChannelName}*** del tuo workspace Slack. Potrai gestire tutte le conversazioni con i clienti direttamente dal canale e non perderai mai un messaggio.\n\nEcco le principali funzionalità dellintegrazione:\n\n**Rispondi alle conversazioni direttamente da Slack:** Per rispondere a una conversazione nel canale Slack ***{selectedChannelName}***, ti basta scrivere il tuo messaggio e inviarlo come thread. Questo genererà automaticamente una risposta al cliente tramite Chatwoot. Semplice, no?\n\n**Crea note private:** Se vuoi aggiungere note private invece di risposte, inizia il messaggio con ***`note:`***. In questo modo il messaggio resterà privato e non sarà visibile al cliente.\n\n**Associa un profilo operatore:** Se la persona che risponde su Slack ha un profilo operatore in Chatwoot con lo stesso indirizzo email, le risposte verranno associate automaticamente a quel profilo. In questo modo potrai sapere facilmente chi ha risposto e quando. Se invece chi risponde non ha un profilo operatore associato, le risposte verranno inviate al cliente dal profilo del bot.",
"BODY": "Con questa integrazione, tutte le conversazioni in arrivo verranno sincronizzate nel canale ***{selectedChannelName}*** del tuo workspace Slack. Potrai gestire tutte le conversazioni con i clienti direttamente dal canale e non perderai mai un messaggio.\n\nEcco le principali funzionalità dellintegrazione:\n\n**Rispondi alle conversazioni direttamente da Slack:** Per rispondere a una conversazione nel canale Slack ***{selectedChannelName}***, ti basta scrivere il tuo messaggio e inviarlo come thread. Questo genererà automaticamente una risposta al cliente tramite Chatwoot. Semplice, no?\n\n**Crea note private:** Se vuoi aggiungere note private invece di risposte, inizia il messaggio con ***`note:`***. In questo modo il messaggio resterà privato e non sarà visibile al cliente.\n\n**Associa un profilo operatore:** Se la persona che risponde su Slack ha un profilo operatore in app con lo stesso indirizzo email, le risposte verranno associate automaticamente a quel profilo. In questo modo potrai sapere facilmente chi ha risposto e quando. Se invece chi risponde non ha un profilo operatore associato, le risposte verranno inviate al cliente dal profilo del bot.",
"SELECTED": "selezionato"
},
"SELECT_CHANNEL": {
@@ -472,7 +472,7 @@
"FEATURES": {
"TITLE": "Funzionalità",
"ALLOW_CONVERSATION_FAQS": "Genera FAQ dalle conversazioni risolte",
"ALLOW_MEMORIES": "Cattura i dettagli chiave come memorie dalle interazioni con i clienti.",
"ALLOW_MEMORIES": "Salva memorie e dettagli chiave dalle interazioni con i clienti.",
"ALLOW_CITATIONS": "Includi citazioni alle fonti nelle risposte"
}
},
@@ -694,8 +694,8 @@
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
"TITLE": "FAQ Correlate",
"DESCRIPTION": "Queste FAQ vengono generate direttamente dai Documenti."
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
@@ -740,7 +740,7 @@
"ERROR_MESSAGE": "Si è verificato un errore durante l'eliminazione del documento, riprova."
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "Visualizza Risposte Correlate",
"VIEW_RELATED_RESPONSES": "Visualizza FAQ Correlate",
"DELETE_DOCUMENT": "Elimina Documento"
},
"EMPTY_STATE": {
@@ -895,7 +895,7 @@
"FILTER": {
"ASSISTANT": "Assistente: {selected}",
"STATUS": "Stato: {selected}",
"ALL_ASSISTANTS": "Tutte"
"ALL_ASSISTANTS": "Tutti"
},
"STATUS": {
"TITLE": "Stato",
@@ -193,7 +193,7 @@
}
},
"LABEL_REPORTS": {
"HEADER": "Panoramica etichette",
"HEADER": "Panoramica Etichette",
"DESCRIPTION": "Monitora la performance delle etichette con metriche chiave, tra cui conversazioni, tempi di risposta, tempi di risoluzione e casi risolti. Fai clic sul nome di un'etichetta per approfondimenti dettagliati.",
"LOADING_CHART": "Caricamento dati grafici...",
"NO_ENOUGH_DATA": "Non ci sono abbastanza dati per generare il report, riprova più tardi.",
@@ -331,7 +331,7 @@
}
},
"TEAM_REPORTS": {
"HEADER": "Panoramica team",
"HEADER": "Panoramica Team",
"DESCRIPTION": "Ottieni un'istantanea delle prestazioni del tuo team con metriche essenziali, tra cui conversazioni, tempi di risposta, tempi di risoluzione e casi risolti. Fare clic su un nome del team per maggiori dettagli.",
"LOADING_CHART": "Caricamento dati grafici...",
"NO_ENOUGH_DATA": "Non ci sono abbastanza dati per generare il report, riprova più tardi.",
@@ -399,7 +399,7 @@
}
},
"CSAT_REPORTS": {
"HEADER": "Rapporti CSAT",
"HEADER": "Report CSAT",
"NO_RECORDS": "Non ci sono risposte ai sondaggi CSAT disponibili.",
"DOWNLOAD": "Scarica report CSAT",
"DOWNLOAD_FAILED": "Download dei report CSAT non riuscito",
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "削除",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "連絡先を削除"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "삭제",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "연락처 지우기"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Ištrinti",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Trinti kontaktą"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "Atlasīti {count}",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Dzēst",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Dzēst kontaktpersonu"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "ഇല്ലാതാക്കുക",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "കോൺടാക്റ്റ് ഇല്ലാതാക്കുക"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Padamkan",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Verwijderen",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Contactpersoon verwijderen"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Slett",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Usuń",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Usuń kontakt"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Selecionar todas ({count})"
"SELECT_ALL": "Selecionar todas ({count})",
"DELETE_CONTACTS": "Excluir",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Excluir contacto"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selecionado",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Selecionar todos ({count})"
"SELECT_ALL": "Selecionar todos ({count})",
"DELETE_CONTACTS": "Excluir",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Excluir contato"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Şterge",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Șterge contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "Выбрано {count}",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Выбрать все ({count})"
"SELECT_ALL": "Выбрать все ({count})",
"DELETE_CONTACTS": "Удалить",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Удалить контакт"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Vymazať",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Vymazať kontakt"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Izbriši",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Obriši kontakt"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Radera",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Ta bort kontakt"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "ลบ",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "ลบผู้ติดต่อ"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -579,7 +579,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -578,8 +578,19 @@
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Tümünü seç ({count})"
"CLEAR_SELECTION": "Seçimi temizle",
"SELECT_ALL": "Tümünü seç ({count})",
"DELETE_CONTACTS": "Sil",
"DELETE_SUCCESS": "Kişiler başarıyla silindi.",
"DELETE_FAILED": "Kişiler silinemedi.",
"DELETE_DIALOG": {
"TITLE": "Seçilen kişileri sil",
"SINGULAR_TITLE": "Seçilen kişiyi sil",
"DESCRIPTION": "Bu işlem, seçilen {count} kişileri kalıcı olarak silecektir. Bu eylem geri alınamaz.",
"SINGULAR_DESCRIPTION": "Bu işlem, seçilen kişiyi kalıcı olarak silecektir. Bu eylem geri alınamaz.",
"CONFIRM_MULTIPLE": "Kişileri sil",
"CONFIRM_SINGLE": "Kişiyi Sil"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
@@ -32,7 +32,7 @@
"LOADING_CONVERSATIONS": "Sohbetler Yükleniyor\n",
"CANNOT_REPLY": "Nedeniyle cevap veremezsiniz",
"24_HOURS_WINDOW": "24 saat mesaj penceresi kısıtlaması",
"API_HOURS_WINDOW": "Bu sohbete yalnızca {hours} saat içinde yanıt verebilirsiniz",
"API_HOURS_WINDOW": "Bu konuşmaya yalnızca {hours} saat içinde cevap verebilirsiniz",
"NOT_ASSIGNED_TO_YOU": "Bu görüşme size atanmamış. Bu konuşmayı kendinize atamak ister misiniz?",
"ASSIGN_TO_ME": "Bana ata",
"BOT_HANDOFF_MESSAGE": "Şu anda bir asistan veya bot tarafından yürütülen bir konuşmaya yanıt veriyorsunuz.",
@@ -42,7 +42,7 @@
"BOT_HANDOFF_ERROR": "Konuşma devralınamadı. Lütfen tekrar deneyin.",
"TWILIO_WHATSAPP_CAN_REPLY": "Bu konuşmaya yalnızca şablon mesaj kullanarak yanıt verebilirsiniz, çünkü",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 saat mesaj penceresi kısıtlaması",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "Bu Instagram hesabı yeni Instagram kanal gelen kutusuna taşındı. Tüm yeni mesajlar orada görünecek. Bu sohbetten artık mesaj gönderemezsiniz.",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "Bu Instagram hesabı, yeni Instagram kanal gelen kutusuna taşındı. Tüm yeni mesajlar orada görünecektir. Artık bu konuşmadan mesaj gönderemeyeceksiniz.",
"REPLYING_TO": "Cevap veriyorsun:",
"REMOVE_SELECTION": "Seçimi Kaldır",
"DOWNLOAD": "İndir",
@@ -139,8 +139,8 @@
}
},
"DELETE_CONVERSATION": {
"TITLE": "#{conversationId} numaralı sohbeti sil",
"DESCRIPTION": "Bu sohbeti silmek istediğinizden emin misiniz?",
"TITLE": "#{conversationId} numaralı konuşmayı sil",
"DESCRIPTION": "Bu konuşmayı silmek istediğinizden emin misiniz?",
"CONFIRM": "Sil"
},
"CARD_CONTEXT_MENU": {
@@ -159,7 +159,7 @@
"ASSIGN_LABEL": "Etiket ata",
"AGENTS_LOADING": "Temsilciler Yükleniyor...",
"ASSIGN_TEAM": "Takım ata",
"DELETE": "Sohbeti sil",
"DELETE": "Konuşmayı sil",
"OPEN_IN_NEW_TAB": "Yeni sekmede aç",
"COPY_LINK": "Konuşma bağlantısını kopyala",
"COPY_LINK_SUCCESS": "Konuşma bağlantısı panoya kopyalandı",
@@ -244,8 +244,8 @@
"ASSIGN_LABEL_SUCCESFUL": "Etiket başarıyla atandı",
"ASSIGN_LABEL_FAILED": "Etiket ataması yapılamadı",
"CHANGE_TEAM": "Takım değişti",
"SUCCESS_DELETE_CONVERSATION": "Sohbet başarıyla silindi",
"FAIL_DELETE_CONVERSATION": "Sohbet silinemedi! Tekrar deneyin",
"SUCCESS_DELETE_CONVERSATION": "Konuşma başarıyla silindi",
"FAIL_DELETE_CONVERSATION": "Konuşma silinemedi! Tekrar deneyin",
"FILE_SIZE_LIMIT": "Dosya, {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB ek dosya sınırını aşıyor",
"MESSAGE_ERROR": "Bu mesaj gönderilemiyor, lütfen daha sonra tekrar deneyin",
"SENT_BY": "Tarafından gönderildi:",
@@ -1,8 +1,8 @@
{
"GENERAL_SETTINGS": {
"LIMIT_MESSAGES": {
"CONVERSATION": "Sohbet sınırını aştınız. Hacker planı yalnızca 500 sohbete izin verir.",
"INBOXES": "Gelen kutusu sınırını aştınız. Hacker planı yalnızca web sitesi canlı sohbetini destekler. E-posta, WhatsApp gibi ek gelen kutuları ücretli plan gerektirir.",
"CONVERSATION": "Konuşma limitini aştınız. Hacker planı yalnızca 500 konuşmaya izin vermektedir.",
"INBOXES": "Gelen kutusu limitini aştınız. Hacker planı yalnızca web sitesi canlı sohbetini desteklemektedir. E-posta, WhatsApp gibi ek gelen kutuları ücretli bir plan gerektirir.",
"AGENTS": "Temsilci sınırını aştınız. Planınız yalnızca {allowedAgents} temsilciye izin veriyor.",
"NON_ADMIN": "Tüm özellikleri kullanmaya devam etmek için lütfen yöneticinizle iletişime geçin ve planı yükseltin."
},
@@ -20,10 +20,10 @@
"BUTTON_TEXT": "Hesabınızı Silin",
"CONFIRM": {
"TITLE": "Hesabı Sil",
"MESSAGE": "Hesabınızı silmek geri alınamaz. Kalıcı olarak silmek istediğinizi onaylamak için aşağıya hesap adınızı girin.",
"MESSAGE": "Hesabınızı silmek geri alınamaz bir işlemdir. Kalıcı olarak silmek istediğinizi onaylamak için aşağıya hesap adınızı girin.",
"BUTTON_TEXT": "Sil",
"DISMISS": "İptal Et",
"PLACE_HOLDER": "{accountName} yazarak onaylayın"
"PLACE_HOLDER": "Onaylamak için lütfen {accountName} yazın"
},
"SUCCESS": "Hesap silinmek üzere işaretlendi",
"FAILURE": "Hesap silinemedi, tekrar deneyin!",
@@ -45,11 +45,11 @@
"NOTE": "API tabanlı bir entegrasyon oluşturuyorsanız bu kimlik gereklidir"
},
"AUTO_RESOLVE": {
"TITLE": "Sohbetleri otomatik çöz",
"NOTE": "Bu yapılandırma, belirli bir re etkinlik olmadığında sohbeti otomatik olarak çözmenizi sağlar.",
"TITLE": "Konuşmaları Otomatik Çöz",
"NOTE": "Bu yapılandırma, belirli bir hareketsizlik süresinden sonra konuşmayı otomatik olarak çözümlemenize olanak tanır.",
"DURATION": {
"LABEL": "Etkinliksizlik süresi",
"HELP": "Sohbetin otomatik olarak çözüleceği etkinliksizlik süresi",
"LABEL": "Hareketsizlik süresi",
"HELP": "Konuşmanın otomatik olarak çözümlenmesinden önceki hareketsizlik süresi",
"PLACEHOLDER": "30",
"ERROR": "Otomatik çözme süresi 10 dakika ile 999 gün arasında olmalıdır",
"API": {
@@ -59,8 +59,8 @@
},
"MESSAGE": {
"LABEL": "Özel otomatik çözüm mesajı",
"PLACEHOLDER": "Sohbet, 15 gün etkinlik olmadığı için sistem tarafından çözüldü olarak işaretlendi",
"HELP": "Sohbet otomatik olarak çözüldükten sonra müşteriye gönderilen mesaj"
"PLACEHOLDER": "Konuşma, 15 günlük hareketsizlik nedeniyle sistem tarafından çözümlendi olarak işaretlendi",
"HELP": "Konuşma otomatik olarak çözüldükten sonra müşteriye gönderilen mesaj"
},
"PREFERENCES": "Tercihler",
"LABEL": {
@@ -94,19 +94,19 @@
},
"AUTO_RESOLVE_IGNORE_WAITING": {
"LABEL": "Yanıtsız sohbetleri hariç tut",
"HELP": "Etkinleştirildiğinde, sistem hâlâ temsilci yanıtı bekleyen sohbetleri çözmeyi atlayacaktır."
"HELP": "Etkinleştirildiğinde, sistem hâlâ bir temsilcinin yanıtını bekleyen konuşmaları çözümlemeyi atlayacaktır."
},
"AUDIO_TRANSCRIPTION": {
"TITLE": "Sesli Mesajları Yazıya Döktür",
"NOTE": "Sohbetlerdeki sesli mesajları otomatik olarak yazıya dökün. Bir sesli mesaj gönderildiğinde veya alındığında bir metin dökümü oluşturun ve mesajın yanında gösterin.",
"TITLE": "Sesli Mesajları Metne Çevir",
"NOTE": "Konuşmalardaki sesli mesajları otomatik olarak metne çevirin. Bir sesli mesaj gönderildiğinde veya alındığında bir metin dökümü (transkript) oluşturun ve mesajın yanında görüntüleyin.",
"API": {
"SUCCESS": "Sesli mesaj transkripsiyon ayarı başarıyla güncellendi",
"ERROR": "Sesli yazıya dökme ayarı güncellenemedi"
"ERROR": "Ses transkripsiyon ayarı güncellenirken hata oluştu"
}
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Çözüm için etkinliksizlik süresi",
"HELP": "Bir sohbette etkinlik yoksa otomatik çözüm için süre",
"LABEL": "Çözümleme için hareketsizlik süresi",
"HELP": "Bir konuşmanın, hiçbir etkinlik olmazsa otomatik olarak çözümlenmesi gereken süre",
"PLACEHOLDER": "30",
"ERROR": "Otomatik çözme süresi 10 dakika ile 999 gün arasında olmalıdır",
"API": {
@@ -115,8 +115,8 @@
},
"UPDATE_BUTTON": "Güncelleme",
"MESSAGE_LABEL": "Özel çözüm mesajı",
"MESSAGE_PLACEHOLDER": "Sohbet, 15 gün etkinlik olmadığı için sistem tarafından çözüldü olarak işaretlendi",
"MESSAGE_HELP": "Bu mesaj, bir sohbet sistem tarafından etkinliksizlik nedeniyle otomatik olarak çözüldüğünde müşteriye gönderilir."
"MESSAGE_PLACEHOLDER": "Konuşma, 15 günlük hareketsizlik nedeniyle sistem tarafından çözümlendi olarak işaretlendi",
"MESSAGE_HELP": "Bu mesaj, bir konuşma hareketsizlik nedeniyle sistem tarafından otomatik olarak çözümlendiğinde müşteriye gönderilir."
},
"FEATURES": {
"INBOUND_EMAIL_ENABLED": "Hesabınız için e-posta ile iletişim devre dışı bırakıldı.",

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