Merge pull request #62 from fazer-ai/chore/merge-upstream
Chore/merge upstream
This commit is contained in:
commit
d449df7cf4
8
Makefile
8
Makefile
@ -44,6 +44,12 @@ force_run:
|
|||||||
rm -f tmp/pids/*.pid
|
rm -f tmp/pids/*.pid
|
||||||
overmind start -f Procfile.dev
|
overmind start -f Procfile.dev
|
||||||
|
|
||||||
|
force_run_tunnel:
|
||||||
|
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||||
|
rm -f ./.overmind.sock
|
||||||
|
rm -f tmp/pids/*.pid
|
||||||
|
overmind start -f Procfile.tunnel
|
||||||
|
|
||||||
debug:
|
debug:
|
||||||
overmind connect backend
|
overmind connect backend
|
||||||
|
|
||||||
@ -53,4 +59,4 @@ debug_worker:
|
|||||||
docker:
|
docker:
|
||||||
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
||||||
|
|
||||||
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
|
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
|
||||||
|
|||||||
4
Procfile.tunnel
Normal file
4
Procfile.tunnel
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
|
||||||
|
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
|
||||||
|
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||||
|
vite: bin/vite build --watch
|
||||||
@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
|
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label)
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@ -134,10 +134,6 @@ class ConversationApi extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${conversationId}/attachments`);
|
return axios.get(`${this.url}/${conversationId}/attachments`);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestCopilot(conversationId, body) {
|
|
||||||
return axios.post(`${this.url}/${conversationId}/copilot`, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
getInboxAssistant(conversationId) {
|
getInboxAssistant(conversationId) {
|
||||||
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
|
|||||||
@ -76,7 +76,7 @@ const handlePageChange = event => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
<header class="sticky top-0 z-10 px-6">
|
||||||
<div class="w-full max-w-[60rem] mx-auto">
|
<div class="w-full max-w-[60rem] mx-auto">
|
||||||
<div
|
<div
|
||||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||||
@ -116,7 +116,7 @@ const handlePageChange = event => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
|
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
|
||||||
<slot v-if="!showPaywall" name="controls" />
|
<slot v-if="!showPaywall" name="controls" />
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, ref, watch } from 'vue';
|
import { nextTick, ref, watch, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
@ -9,23 +8,17 @@ import CopilotInput from './CopilotInput.vue';
|
|||||||
import CopilotLoader from './CopilotLoader.vue';
|
import CopilotLoader from './CopilotLoader.vue';
|
||||||
import CopilotAgentMessage from './CopilotAgentMessage.vue';
|
import CopilotAgentMessage from './CopilotAgentMessage.vue';
|
||||||
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
||||||
|
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
|
||||||
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import CopilotEmptyState from './CopilotEmptyState.vue';
|
||||||
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
supportAgent: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
messages: {
|
messages: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
isCaptainTyping: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
conversationInboxType: {
|
conversationInboxType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@ -44,18 +37,11 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const COPILOT_USER_ROLES = ['assistant', 'system'];
|
|
||||||
|
|
||||||
const sendMessage = message => {
|
const sendMessage = message => {
|
||||||
emit('sendMessage', message);
|
emit('sendMessage', message);
|
||||||
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
|
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useSuggestion = opt => {
|
|
||||||
emit('sendMessage', t(opt.prompt));
|
|
||||||
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatContainer = ref(null);
|
const chatContainer = ref(null);
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
@ -65,20 +51,40 @@ const scrollToBottom = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptOptions = [
|
const groupedMessages = computed(() => {
|
||||||
{
|
const result = [];
|
||||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
|
let thinkingGroup = [];
|
||||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
|
props.messages.forEach(message => {
|
||||||
},
|
if (message.message_type === 'assistant_thinking') {
|
||||||
{
|
thinkingGroup.push(message);
|
||||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
|
} else {
|
||||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
|
if (thinkingGroup.length > 0) {
|
||||||
},
|
result.push({
|
||||||
{
|
id: thinkingGroup[0].id,
|
||||||
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
|
message_type: 'thinking_group',
|
||||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
|
messages: thinkingGroup,
|
||||||
},
|
});
|
||||||
];
|
thinkingGroup = [];
|
||||||
|
}
|
||||||
|
result.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (thinkingGroup.length > 0) {
|
||||||
|
result.push({
|
||||||
|
id: thinkingGroup[0].id,
|
||||||
|
message_type: 'thinking_group',
|
||||||
|
messages: thinkingGroup,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLastMessageFromAssistant = computed(() => {
|
||||||
|
return (
|
||||||
|
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
|
||||||
|
'assistant'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const { updateUISettings } = useUISettings();
|
const { updateUISettings } = useUISettings();
|
||||||
|
|
||||||
@ -95,8 +101,22 @@ const handleSidebarAction = action => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasAssistants = computed(() => props.assistants.length > 0);
|
||||||
|
const hasMessages = computed(() => props.messages.length > 0);
|
||||||
|
const copilotButtons = computed(() => {
|
||||||
|
if (hasMessages.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'reset',
|
||||||
|
icon: 'i-lucide-refresh-ccw',
|
||||||
|
tooltip: t('CAPTAIN.COPILOT.RESET'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
watch(
|
watch(
|
||||||
[() => props.messages, () => props.isCaptainTyping],
|
[() => props.messages],
|
||||||
() => {
|
() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
},
|
},
|
||||||
@ -108,64 +128,57 @@ watch(
|
|||||||
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
|
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
|
||||||
<SidebarActionsHeader
|
<SidebarActionsHeader
|
||||||
:title="$t('CAPTAIN.COPILOT.TITLE')"
|
:title="$t('CAPTAIN.COPILOT.TITLE')"
|
||||||
:buttons="[
|
:buttons="copilotButtons"
|
||||||
{
|
|
||||||
key: 'reset',
|
|
||||||
icon: 'i-lucide-refresh-ccw',
|
|
||||||
tooltip: $t('CAPTAIN.COPILOT.RESET'),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
@click="handleSidebarAction"
|
@click="handleSidebarAction"
|
||||||
@close="closeCopilotPanel"
|
@close="closeCopilotPanel"
|
||||||
/>
|
/>
|
||||||
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
|
|
||||||
<template v-for="message in messages" :key="message.id">
|
|
||||||
<CopilotAgentMessage
|
|
||||||
v-if="message.role === 'user'"
|
|
||||||
:support-agent="supportAgent"
|
|
||||||
:message="message"
|
|
||||||
/>
|
|
||||||
<CopilotAssistantMessage
|
|
||||||
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
|
|
||||||
:message="message"
|
|
||||||
:conversation-inbox-type="conversationInboxType"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<CopilotLoader v-if="isCaptainTyping" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!messages.length"
|
ref="chatContainer"
|
||||||
class="h-full w-full flex items-center justify-center"
|
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
|
||||||
>
|
>
|
||||||
<div class="h-fit px-3 py-3 space-y-1">
|
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full">
|
||||||
<span class="text-xs text-n-slate-10">
|
<template v-for="(item, index) in groupedMessages" :key="item.id">
|
||||||
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
|
<CopilotAgentMessage
|
||||||
</span>
|
v-if="item.message_type === 'user'"
|
||||||
<button
|
:message="item.message"
|
||||||
v-for="prompt in promptOptions"
|
/>
|
||||||
:key="prompt.label"
|
<CopilotAssistantMessage
|
||||||
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
|
v-else-if="item.message_type === 'assistant'"
|
||||||
@click="() => useSuggestion(prompt)"
|
:message="item.message"
|
||||||
>
|
:is-last-message="index === groupedMessages.length - 1"
|
||||||
<span>{{ t(prompt.label) }}</span>
|
:conversation-inbox-type="conversationInboxType"
|
||||||
<Icon icon="i-lucide-chevron-right" />
|
/>
|
||||||
</button>
|
<CopilotThinkingGroup
|
||||||
|
v-else
|
||||||
|
:messages="item.messages"
|
||||||
|
:default-collapsed="isLastMessageFromAssistant"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<CopilotLoader v-if="!isLastMessageFromAssistant" />
|
||||||
</div>
|
</div>
|
||||||
|
<CopilotEmptyState
|
||||||
|
v-else
|
||||||
|
:has-assistants="hasAssistants"
|
||||||
|
@use-suggestion="sendMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-3 mt-px mb-2">
|
<div class="mx-3 mt-px mb-2">
|
||||||
<div class="flex items-center gap-2 justify-between w-full mb-1">
|
<div class="flex items-center gap-2 justify-between w-full mb-1">
|
||||||
<ToggleCopilotAssistant
|
<ToggleCopilotAssistant
|
||||||
v-if="assistants.length"
|
v-if="assistants.length > 1"
|
||||||
:assistants="assistants"
|
:assistants="assistants"
|
||||||
:active-assistant="activeAssistant"
|
:active-assistant="activeAssistant"
|
||||||
@set-assistant="$event => emit('setAssistant', $event)"
|
@set-assistant="$event => emit('setAssistant', $event)"
|
||||||
/>
|
/>
|
||||||
<div v-else />
|
<div v-else />
|
||||||
</div>
|
</div>
|
||||||
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
<CopilotInput
|
||||||
|
v-if="hasAssistants"
|
||||||
|
class="mb-1 w-full"
|
||||||
|
@send="sendMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
isLastMessage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
message: {
|
message: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
@ -20,6 +24,15 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
||||||
|
|
||||||
|
const showUseButton = computed(() => {
|
||||||
|
return (
|
||||||
|
!hasEmptyMessageContent.value &&
|
||||||
|
props.message.reply_suggestion &&
|
||||||
|
props.isLastMessage
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const messageContent = computed(() => {
|
const messageContent = computed(() => {
|
||||||
const formatter = new MessageFormatter(props.message.content);
|
const formatter = new MessageFormatter(props.message.content);
|
||||||
@ -32,8 +45,6 @@ const insertIntoRichEditor = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
|
||||||
|
|
||||||
const useCopilotResponse = () => {
|
const useCopilotResponse = () => {
|
||||||
if (insertIntoRichEditor.value) {
|
if (insertIntoRichEditor.value) {
|
||||||
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
||||||
@ -57,7 +68,7 @@ const useCopilotResponse = () => {
|
|||||||
/>
|
/>
|
||||||
<div class="flex flex-row mt-1">
|
<div class="flex flex-row mt-1">
|
||||||
<Button
|
<Button
|
||||||
v-if="!hasEmptyMessageContent"
|
v-if="showUseButton"
|
||||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||||
faded
|
faded
|
||||||
sm
|
sm
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import Icon from '../icon/Icon.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
hasAssistants: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['useSuggestion']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const routePromptMap = {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dashboard: [
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
|
||||||
|
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentRoute = () => {
|
||||||
|
const path = route.path;
|
||||||
|
if (path.includes('/conversations')) return 'conversations';
|
||||||
|
if (path.includes('/dashboard')) return 'dashboard';
|
||||||
|
if (path.includes('/contacts')) return 'contacts';
|
||||||
|
if (path.includes('/articles')) return 'articles';
|
||||||
|
return 'dashboard';
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptOptions = computed(() => {
|
||||||
|
const currentRoute = getCurrentRoute();
|
||||||
|
return routePromptMap[currentRoute] || routePromptMap.conversations;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSuggestion = opt => {
|
||||||
|
emit('useSuggestion', t(opt.prompt));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 flex flex-col gap-6 px-2">
|
||||||
|
<div class="flex flex-col space-y-4 py-4">
|
||||||
|
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-base font-medium text-n-slate-12 leading-8">
|
||||||
|
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-n-slate-11 leading-6">
|
||||||
|
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasAssistants" class="w-full space-y-2">
|
||||||
|
<p class="text-sm text-n-slate-11 leading-6">
|
||||||
|
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
|
||||||
|
</p>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'captain_assistants_index',
|
||||||
|
params: { accountId: route.params.accountId },
|
||||||
|
}"
|
||||||
|
class="text-n-slate-11 underline hover:text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full space-y-2">
|
||||||
|
<span class="text-xs text-n-slate-10 block">
|
||||||
|
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
|
||||||
|
</span>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<button
|
||||||
|
v-for="prompt in promptOptions"
|
||||||
|
:key="prompt.label"
|
||||||
|
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
|
||||||
|
@click="handleSuggestion(prompt)"
|
||||||
|
>
|
||||||
|
<span>{{ t(prompt.label) }}</span>
|
||||||
|
<Icon icon="i-lucide-chevron-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const isConversationRoute = computed(() => {
|
||||||
|
const CONVERSATION_ROUTES = [
|
||||||
|
'inbox_conversation',
|
||||||
|
'conversation_through_inbox',
|
||||||
|
'conversations_through_label',
|
||||||
|
'team_conversations_through_label',
|
||||||
|
'conversations_through_folders',
|
||||||
|
'conversation_through_mentions',
|
||||||
|
'conversation_through_unattended',
|
||||||
|
'conversation_through_participating',
|
||||||
|
];
|
||||||
|
return CONVERSATION_ROUTES.includes(route.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
|
const showCopilotLauncher = computed(() => {
|
||||||
|
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||||
|
currentAccountId.value,
|
||||||
|
FEATURE_FLAGS.CAPTAIN
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
isCaptainEnabled &&
|
||||||
|
!uiSettings.value.is_copilot_panel_open &&
|
||||||
|
!isConversationRoute.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
|
||||||
|
<div class="rounded-full bg-n-alpha-2 p-1">
|
||||||
|
<Button
|
||||||
|
icon="i-woot-captain"
|
||||||
|
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
||||||
|
lg
|
||||||
|
@click="toggleSidebar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else />
|
||||||
|
</template>
|
||||||
@ -17,7 +17,7 @@ defineProps({
|
|||||||
icon="i-lucide-sparkles"
|
icon="i-lucide-sparkles"
|
||||||
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
|
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
|
||||||
/>
|
/>
|
||||||
<div class="text-sm text-n-slate-11">
|
<div class="text-sm text-n-slate-12">
|
||||||
{{ content }}
|
{{ content }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -51,10 +51,10 @@ watch(
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<CopilotThinkingBlock
|
<CopilotThinkingBlock
|
||||||
v-for="message in messages"
|
v-for="copilotMessage in messages"
|
||||||
:key="message.id"
|
:key="copilotMessage.id"
|
||||||
:content="message.content"
|
:content="copilotMessage.message.content"
|
||||||
:reasoning="message.reasoning"
|
:reasoning="copilotMessage.message.reasoning"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -74,6 +74,7 @@ const updateSelected = newValue => {
|
|||||||
<slot name="trigger" :toggle="toggle">
|
<slot name="trigger" :toggle="toggle">
|
||||||
<Button
|
<Button
|
||||||
ref="triggerRef"
|
ref="triggerRef"
|
||||||
|
type="button"
|
||||||
sm
|
sm
|
||||||
slate
|
slate
|
||||||
:variant
|
:variant
|
||||||
|
|||||||
@ -9,7 +9,14 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
|||||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||||
|
|
||||||
const { options } = defineProps({
|
const {
|
||||||
|
options,
|
||||||
|
disableSearch,
|
||||||
|
placeholderIcon,
|
||||||
|
placeholder,
|
||||||
|
placeholderTrailingIcon,
|
||||||
|
searchPlaceholder,
|
||||||
|
} = defineProps({
|
||||||
options: {
|
options: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
@ -18,6 +25,22 @@ const { options } = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
placeholderIcon: {
|
||||||
|
type: String,
|
||||||
|
default: 'i-lucide-plus',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
placeholderTrailingIcon: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
searchPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -69,15 +92,26 @@ const toggleSelected = option => {
|
|||||||
sm
|
sm
|
||||||
slate
|
slate
|
||||||
faded
|
faded
|
||||||
|
type="button"
|
||||||
:icon="selectedItem.icon"
|
:icon="selectedItem.icon"
|
||||||
:label="selectedItem.name"
|
:label="selectedItem.name"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
/>
|
/>
|
||||||
<Button v-else sm slate faded @click="toggle">
|
<Button
|
||||||
|
v-else
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
type="button"
|
||||||
|
:trailing-icon="placeholderTrailingIcon"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
|
<Icon :icon="placeholderIcon" class="text-n-slate-11" />
|
||||||
</template>
|
</template>
|
||||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
<span class="text-n-slate-11">{{
|
||||||
|
placeholder || t('COMBOBOX.PLACEHOLDER')
|
||||||
|
}}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="top-0 min-w-56 z-50" strong>
|
<DropdownBody class="top-0 min-w-56 z-50" strong>
|
||||||
@ -87,7 +121,7 @@ const toggleSelected = option => {
|
|||||||
v-model="searchTerm"
|
v-model="searchTerm"
|
||||||
autofocus
|
autofocus
|
||||||
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
||||||
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownSection class="max-h-80 overflow-scroll">
|
<DropdownSection class="max-h-80 overflow-scroll">
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import Input from './Input.vue';
|
import Input from './Input.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { DURATION_UNITS } from './constants';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
min: { type: Number, default: 0 },
|
min: { type: Number, default: 0 },
|
||||||
@ -11,36 +12,50 @@ const props = defineProps({
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const duration = defineModel('modelValue', { type: Number, default: null });
|
const duration = defineModel('modelValue', { type: Number, default: null });
|
||||||
|
const unit = defineModel('unit', {
|
||||||
|
type: String,
|
||||||
|
default: DURATION_UNITS.MINUTES,
|
||||||
|
validate(value) {
|
||||||
|
return Object.values(DURATION_UNITS).includes(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const UNIT_TYPES = {
|
const convertToMinutes = newValue => {
|
||||||
MINUTES: 'minutes',
|
if (unit.value === DURATION_UNITS.MINUTES) {
|
||||||
HOURS: 'hours',
|
return Math.floor(newValue);
|
||||||
DAYS: 'days',
|
}
|
||||||
|
if (unit.value === DURATION_UNITS.HOURS) {
|
||||||
|
return Math.floor(newValue) * 60;
|
||||||
|
}
|
||||||
|
return Math.floor(newValue) * 24 * 60;
|
||||||
};
|
};
|
||||||
const unit = ref(UNIT_TYPES.MINUTES);
|
|
||||||
|
|
||||||
const transformedValue = computed({
|
const transformedValue = computed({
|
||||||
get() {
|
get() {
|
||||||
if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
|
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
|
||||||
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
|
if (unit.value === DURATION_UNITS.HOURS)
|
||||||
if (unit.value === UNIT_TYPES.DAYS)
|
return Math.floor(duration.value / 60);
|
||||||
|
if (unit.value === DURATION_UNITS.DAYS)
|
||||||
return Math.floor(duration.value / 24 / 60);
|
return Math.floor(duration.value / 24 / 60);
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
set(newValue) {
|
set(newValue) {
|
||||||
let minuteValue;
|
let minuteValue = convertToMinutes(newValue);
|
||||||
if (unit.value === UNIT_TYPES.MINUTES) {
|
|
||||||
minuteValue = Math.floor(newValue);
|
|
||||||
} else if (unit.value === UNIT_TYPES.HOURS) {
|
|
||||||
minuteValue = Math.floor(newValue * 60);
|
|
||||||
} else if (unit.value === UNIT_TYPES.DAYS) {
|
|
||||||
minuteValue = Math.floor(newValue * 24 * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
|
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// when unit is changed set the nearest value to that unit
|
||||||
|
// so if the minute is set to 900, and the user changes the unit to "days"
|
||||||
|
// the transformed value will show 0, but the real value will still be 900
|
||||||
|
// this might create some confusion, especially when saving
|
||||||
|
// this watcher fixes it by rounding the duration basically, to the nearest unit value
|
||||||
|
watch(unit, () => {
|
||||||
|
let adjustedValue = convertToMinutes(transformedValue.value);
|
||||||
|
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -57,10 +72,12 @@ const transformedValue = computed({
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
|
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<option :value="UNIT_TYPES.MINUTES">
|
<option :value="DURATION_UNITS.MINUTES">
|
||||||
{{ t('DURATION_INPUT.MINUTES') }}
|
{{ t('DURATION_INPUT.MINUTES') }}
|
||||||
</option>
|
</option>
|
||||||
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
|
<option :value="DURATION_UNITS.HOURS">
|
||||||
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
|
{{ t('DURATION_INPUT.HOURS') }}
|
||||||
|
</option>
|
||||||
|
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
export const DURATION_UNITS = {
|
||||||
|
MINUTES: 'minutes',
|
||||||
|
HOURS: 'hours',
|
||||||
|
DAYS: 'days',
|
||||||
|
};
|
||||||
@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative flex items-center justify-end resolve-actions">
|
<div class="relative flex items-center justify-end resolve-actions">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg shadow outline-1 outline"
|
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
||||||
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@ -178,7 +178,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
<div
|
<div
|
||||||
v-if="showActionsDropdown"
|
v-if="showActionsDropdown"
|
||||||
v-on-clickaway="closeDropdown"
|
v-on-clickaway="closeDropdown"
|
||||||
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 ltr:right-0 rtl:left-0 max-w-[12.5rem] min-w-[9.75rem]"
|
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 start-0 xl:start-auto xl:end-0 max-w-[12.5rem] min-w-[9.75rem]"
|
||||||
>
|
>
|
||||||
<WootDropdownMenu class="mb-0">
|
<WootDropdownMenu class="mb-0">
|
||||||
<WootDropdownItem v-if="!isPending">
|
<WootDropdownItem v-if="!isPending">
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watchEffect } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||||
import ConversationAPI from 'dashboard/api/inbox/conversation';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
conversationId: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
conversationInboxType: {
|
conversationInboxType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@ -20,12 +15,24 @@ const props = defineProps({
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
|
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||||
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
|
||||||
|
const selectedCopilotThreadId = ref(null);
|
||||||
|
const messages = computed(() =>
|
||||||
|
store.getters['copilotMessages/getMessagesByThreadId'](
|
||||||
|
selectedCopilotThreadId.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
const messages = ref([]);
|
|
||||||
const isCaptainTyping = ref(false);
|
|
||||||
const selectedAssistantId = ref(null);
|
const selectedAssistantId = ref(null);
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
const activeAssistant = computed(() => {
|
const activeAssistant = computed(() => {
|
||||||
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||||
@ -55,68 +62,57 @@ const setAssistant = async assistant => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldShowCopilotPanel = computed(() => {
|
||||||
|
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||||
|
currentAccountId.value,
|
||||||
|
FEATURE_FLAGS.CAPTAIN
|
||||||
|
);
|
||||||
|
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
|
||||||
|
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
|
||||||
|
});
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
messages.value = [];
|
selectedCopilotThreadId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = async message => {
|
const sendMessage = async message => {
|
||||||
// Add user message
|
if (selectedCopilotThreadId.value) {
|
||||||
messages.value.push({
|
await store.dispatch('copilotMessages/create', {
|
||||||
id: messages.value.length + 1,
|
assistant_id: activeAssistant.value.id,
|
||||||
role: 'user',
|
conversation_id: currentChat.value?.id,
|
||||||
content: message,
|
threadId: selectedCopilotThreadId.value,
|
||||||
});
|
message,
|
||||||
isCaptainTyping.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await ConversationAPI.requestCopilot(
|
|
||||||
props.conversationId,
|
|
||||||
{
|
|
||||||
previous_history: messages.value
|
|
||||||
.map(m => ({
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
}))
|
|
||||||
.slice(0, -1),
|
|
||||||
message,
|
|
||||||
assistant_id: selectedAssistantId.value,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
messages.value.push({
|
|
||||||
id: new Date().getTime(),
|
|
||||||
role: 'assistant',
|
|
||||||
content: data.message,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} else {
|
||||||
// eslint-disable-next-line
|
const response = await store.dispatch('copilotThreads/create', {
|
||||||
console.log(error);
|
assistant_id: activeAssistant.value.id,
|
||||||
} finally {
|
conversation_id: currentChat.value?.id,
|
||||||
isCaptainTyping.value = false;
|
message,
|
||||||
|
});
|
||||||
|
selectedCopilotThreadId.value = response.id;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('captainAssistants/get');
|
store.dispatch('captainAssistants/get');
|
||||||
});
|
});
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
if (props.conversationId) {
|
|
||||||
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
|
|
||||||
selectedAssistantId.value = activeAssistant.value?.id;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Copilot
|
<div
|
||||||
:messages="messages"
|
v-if="shouldShowCopilotPanel"
|
||||||
:support-agent="currentUser"
|
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
|
||||||
:is-captain-typing="isCaptainTyping"
|
>
|
||||||
:conversation-inbox-type="conversationInboxType"
|
<Copilot
|
||||||
:assistants="assistants"
|
:messages="messages"
|
||||||
:active-assistant="activeAssistant"
|
:support-agent="currentUser"
|
||||||
@set-assistant="setAssistant"
|
:conversation-inbox-type="conversationInboxType"
|
||||||
@send-message="sendMessage"
|
:assistants="assistants"
|
||||||
@reset="handleReset"
|
:active-assistant="activeAssistant"
|
||||||
/>
|
@set-assistant="setAssistant"
|
||||||
|
@send-message="sendMessage"
|
||||||
|
@reset="handleReset"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -25,10 +25,7 @@ defineProps({
|
|||||||
:username="user.name"
|
:username="user.name"
|
||||||
:status="user.availability_status"
|
:status="user.availability_status"
|
||||||
/>
|
/>
|
||||||
<span
|
<span class="my-0 truncate text-capitalize" :class="textClass">
|
||||||
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
|
||||||
:class="textClass"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -107,10 +107,10 @@ const isLinearFeatureEnabled = computed(() =>
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="conversationHeader"
|
ref="conversationHeader"
|
||||||
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
|
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
|
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
|
||||||
>
|
>
|
||||||
<BackButton
|
<BackButton
|
||||||
v-if="showBackButton"
|
v-if="showBackButton"
|
||||||
@ -122,11 +122,12 @@ const isLinearFeatureEnabled = computed(() =>
|
|||||||
:username="currentContact.name"
|
:username="currentContact.name"
|
||||||
:status="currentContact.availability_status"
|
:status="currentContact.availability_status"
|
||||||
size="32px"
|
size="32px"
|
||||||
|
class="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
|
||||||
>
|
>
|
||||||
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
|
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium truncate leading-tight text-n-slate-12"
|
class="text-sm font-medium truncate leading-tight text-n-slate-12"
|
||||||
>
|
>
|
||||||
@ -136,7 +137,7 @@ const isLinearFeatureEnabled = computed(() =>
|
|||||||
v-if="!isHMACVerified"
|
v-if="!isHMACVerified"
|
||||||
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
||||||
size="14"
|
size="14"
|
||||||
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
|
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
|
||||||
icon="warning"
|
icon="warning"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -152,20 +153,20 @@ const isLinearFeatureEnabled = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
|
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap"
|
||||||
>
|
>
|
||||||
<SLACardLabel
|
<SLACardLabel
|
||||||
v-if="hasSlaPolicyId"
|
v-if="hasSlaPolicyId"
|
||||||
:chat="chat"
|
:chat="chat"
|
||||||
show-extended-info
|
show-extended-info
|
||||||
:parent-width="width"
|
:parent-width="width"
|
||||||
class="hidden lg:flex"
|
class="hidden md:flex"
|
||||||
/>
|
/>
|
||||||
<Linear
|
<Linear
|
||||||
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
:parent-width="width"
|
:parent-width="width"
|
||||||
class="hidden lg:flex"
|
class="hidden md:flex"
|
||||||
/>
|
/>
|
||||||
<MoreActions :conversation-id="currentChat.id" />
|
<MoreActions :conversation-id="currentChat.id" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,43 +1,23 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
|
||||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
currentChat: {
|
currentChat: {
|
||||||
required: true,
|
required: true,
|
||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
|
||||||
|
|
||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
|
||||||
const isFeatureEnabledonAccount = useMapGetter(
|
|
||||||
'accounts/isFeatureEnabledonAccount'
|
|
||||||
);
|
|
||||||
|
|
||||||
const showCopilotTab = computed(() =>
|
|
||||||
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
|
||||||
);
|
|
||||||
|
|
||||||
const { uiSettings } = useUISettings();
|
const { uiSettings } = useUISettings();
|
||||||
|
|
||||||
const activeTab = computed(() => {
|
const activeTab = computed(() => {
|
||||||
const {
|
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
|
||||||
is_contact_sidebar_open: isContactSidebarOpen,
|
|
||||||
is_copilot_panel_open: isCopilotPanelOpen,
|
|
||||||
} = uiSettings.value;
|
|
||||||
|
|
||||||
if (isContactSidebarOpen) {
|
if (isContactSidebarOpen) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (isCopilotPanelOpen) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -52,13 +32,6 @@ const activeTab = computed(() => {
|
|||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
:inbox-id="currentChat.inbox_id"
|
:inbox-id="currentChat.inbox_id"
|
||||||
/>
|
/>
|
||||||
<CopilotContainer
|
|
||||||
v-show="activeTab === 1 && showCopilotTab"
|
|
||||||
:key="currentChat.id"
|
|
||||||
:conversation-inbox-type="channelType"
|
|
||||||
:conversation-id="currentChat.id"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -134,7 +134,7 @@ onUnmounted(() => {
|
|||||||
<SLAPopoverCard
|
<SLAPopoverCard
|
||||||
v-if="showSlaPopoverCard"
|
v-if="showSlaPopoverCard"
|
||||||
:sla-missed-events="slaEvents"
|
:sla-missed-events="slaEvents"
|
||||||
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
|
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -142,7 +142,7 @@ onMounted(() => {
|
|||||||
v-if="linkedIssue"
|
v-if="linkedIssue"
|
||||||
:issue="linkedIssue.issue"
|
:issue="linkedIssue.issue"
|
||||||
:link-id="linkedIssue.id"
|
:link-id="linkedIssue.id"
|
||||||
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
|
class="absolute start-0 xl:start-auto xl:end-0 top-9 invisible group-hover:visible"
|
||||||
@unlink-issue="unlinkIssue"
|
@unlink-issue="unlinkIssue"
|
||||||
/>
|
/>
|
||||||
<woot-modal
|
<woot-modal
|
||||||
|
|||||||
@ -45,9 +45,10 @@ export function useAccount() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAccount = async data => {
|
const updateAccount = async (data, options) => {
|
||||||
await store.dispatch('accounts/update', {
|
await store.dispatch('accounts/update', {
|
||||||
...data,
|
...data,
|
||||||
|
options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -46,7 +46,31 @@
|
|||||||
},
|
},
|
||||||
"AUTO_RESOLVE": {
|
"AUTO_RESOLVE": {
|
||||||
"TITLE": "Auto-resolve conversations",
|
"TITLE": "Auto-resolve conversations",
|
||||||
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below."
|
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.",
|
||||||
|
"DURATION": {
|
||||||
|
"LABEL": "Inactivity duration",
|
||||||
|
"HELP": "Time period of inactivity after which conversation is auto-resolved",
|
||||||
|
"PLACEHOLDER": "30",
|
||||||
|
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
|
||||||
|
"API": {
|
||||||
|
"SUCCESS": "Auto resolve settings updated successfully",
|
||||||
|
"ERROR": "Failed to update auto resolve settings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MESSAGE": {
|
||||||
|
"LABEL": "Custom auto-resolution message",
|
||||||
|
"PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
|
||||||
|
"HELP": "Message sent to the customer after conversation is auto-resolved"
|
||||||
|
},
|
||||||
|
"PREFERENCES": "Preferences",
|
||||||
|
"LABEL": {
|
||||||
|
"LABEL": "Add label after auto-resolution",
|
||||||
|
"PLACEHOLDER": "Select a label"
|
||||||
|
},
|
||||||
|
"IGNORE_WAITING": {
|
||||||
|
"LABEL": "Skip conversations waiting for agent’s reply"
|
||||||
|
},
|
||||||
|
"UPDATE_BUTTON": "Save Changes"
|
||||||
},
|
},
|
||||||
"NAME": {
|
"NAME": {
|
||||||
"LABEL": "Account name",
|
"LABEL": "Account name",
|
||||||
@ -68,24 +92,6 @@
|
|||||||
"PLACEHOLDER": "Your company's support email",
|
"PLACEHOLDER": "Your company's support email",
|
||||||
"ERROR": ""
|
"ERROR": ""
|
||||||
},
|
},
|
||||||
"AUTO_RESOLVE_IGNORE_WAITING": {
|
|
||||||
"LABEL": "Exclude unattended conversations",
|
|
||||||
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent’s reply."
|
|
||||||
},
|
|
||||||
"AUTO_RESOLVE_DURATION": {
|
|
||||||
"LABEL": "Inactivity duration for resolution",
|
|
||||||
"HELP": "Duration after a conversation should auto resolve if there is no activity",
|
|
||||||
"PLACEHOLDER": "30",
|
|
||||||
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
|
|
||||||
"API": {
|
|
||||||
"SUCCESS": "Auto resolve settings updated successfully",
|
|
||||||
"ERROR": "Failed to update auto resolve settings"
|
|
||||||
},
|
|
||||||
"UPDATE_BUTTON": "Update",
|
|
||||||
"MESSAGE_LABEL": "Custom resolution message",
|
|
||||||
"MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
|
|
||||||
"MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity."
|
|
||||||
},
|
|
||||||
"FEATURES": {
|
"FEATURES": {
|
||||||
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
|
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
|
||||||
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
|
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
|
||||||
|
|||||||
@ -339,6 +339,9 @@
|
|||||||
"HEADER_KNOW_MORE": "Know more",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
"TITLE": "Copilot",
|
"TITLE": "Copilot",
|
||||||
|
"TRY_THESE_PROMPTS": "Try these prompts",
|
||||||
|
"PANEL_TITLE": "Get started with Copilot",
|
||||||
|
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilot’s here to speed things up.",
|
||||||
"SEND_MESSAGE": "Send message...",
|
"SEND_MESSAGE": "Send message...",
|
||||||
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
||||||
"LOADER": "Captain is thinking",
|
"LOADER": "Captain is thinking",
|
||||||
@ -359,6 +362,14 @@
|
|||||||
"RATE": {
|
"RATE": {
|
||||||
"LABEL": "Rate this conversation",
|
"LABEL": "Rate this conversation",
|
||||||
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
|
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
|
||||||
|
},
|
||||||
|
"HIGH_PRIORITY": {
|
||||||
|
"LABEL": "High priority conversations",
|
||||||
|
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
|
||||||
|
},
|
||||||
|
"LIST_CONTACTS": {
|
||||||
|
"LABEL": "List contacts",
|
||||||
|
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -392,6 +403,7 @@
|
|||||||
},
|
},
|
||||||
"ASSISTANTS": {
|
"ASSISTANTS": {
|
||||||
"HEADER": "Assistants",
|
"HEADER": "Assistants",
|
||||||
|
"NO_ASSISTANTS_AVAILABLE": "There are no assistants available in your account.",
|
||||||
"ADD_NEW": "Create a new assistant",
|
"ADD_NEW": "Create a new assistant",
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"TITLE": "Are you sure to delete the assistant?",
|
"TITLE": "Are you sure to delete the assistant?",
|
||||||
|
|||||||
@ -25,6 +25,8 @@ const Sidebar = defineAsyncComponent(
|
|||||||
() => import('../../components/layout/Sidebar.vue')
|
() => import('../../components/layout/Sidebar.vue')
|
||||||
);
|
);
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||||
|
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -37,6 +39,8 @@ export default {
|
|||||||
AddLabelModal,
|
AddLabelModal,
|
||||||
NotificationPanel,
|
NotificationPanel,
|
||||||
UpgradePage,
|
UpgradePage,
|
||||||
|
CopilotLauncher,
|
||||||
|
CopilotContainer,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const upgradePageRef = ref(null);
|
const upgradePageRef = ref(null);
|
||||||
@ -219,6 +223,9 @@ export default {
|
|||||||
<template v-if="!showUpgradePage">
|
<template v-if="!showUpgradePage">
|
||||||
<router-view />
|
<router-view />
|
||||||
<CommandBar />
|
<CommandBar />
|
||||||
|
<CopilotLauncher />
|
||||||
|
<CopilotContainer />
|
||||||
|
|
||||||
<NotificationPanel
|
<NotificationPanel
|
||||||
v-if="isNotificationPanel"
|
v-if="isNotificationPanel"
|
||||||
@close="closeNotificationPanel"
|
@close="closeNotificationPanel"
|
||||||
|
|||||||
@ -16,7 +16,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
|
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6"
|
||||||
>
|
>
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<keep-alive v-if="keepAlive">
|
<keep-alive v-if="keepAlive">
|
||||||
|
|||||||
@ -97,11 +97,8 @@ export default {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
|
||||||
is_contact_sidebar_open: isContactSidebarOpen,
|
return isContactSidebarOpen;
|
||||||
is_copilot_panel_open: isCopilotPanelOpen,
|
|
||||||
} = this.uiSettings;
|
|
||||||
return isContactSidebarOpen || isCopilotPanelOpen;
|
|
||||||
},
|
},
|
||||||
showPopOverSearch() {
|
showPopOverSearch() {
|
||||||
return !this.isFeatureEnabledonAccount(
|
return !this.isFeatureEnabledonAccount(
|
||||||
@ -208,7 +205,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex w-full h-full">
|
<section class="flex w-full h-full min-w-0">
|
||||||
<ChatList
|
<ChatList
|
||||||
:show-conversation-list="showConversationList"
|
:show-conversation-list="showConversationList"
|
||||||
:conversation-inbox="inboxId"
|
:conversation-inbox="inboxId"
|
||||||
|
|||||||
@ -183,7 +183,6 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col w-full h-full">
|
<div v-else class="flex flex-col w-full h-full">
|
||||||
<InboxItemHeader
|
<InboxItemHeader
|
||||||
class="flex-1"
|
|
||||||
:total-length="totalNotificationCount"
|
:total-length="totalNotificationCount"
|
||||||
:current-index="activeNotificationIndex"
|
:current-index="activeNotificationIndex"
|
||||||
:active-notification="activeNotification"
|
:active-notification="activeNotification"
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
|
class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<BackButton
|
<BackButton
|
||||||
|
|||||||
@ -1,35 +1,78 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { h, ref, watch, computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import SectionLayout from './SectionLayout.vue';
|
import SectionLayout from './SectionLayout.vue';
|
||||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||||
import DurationInput from 'next/input/DurationInput.vue';
|
|
||||||
import TextArea from 'next/textarea/TextArea.vue';
|
import TextArea from 'next/textarea/TextArea.vue';
|
||||||
import Switch from 'next/switch/Switch.vue';
|
import Switch from 'next/switch/Switch.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DurationInput from 'next/input/DurationInput.vue';
|
||||||
|
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
|
||||||
|
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const duration = ref(0);
|
const duration = ref(0);
|
||||||
|
const unit = ref(DURATION_UNITS.MINUTES);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
|
const labelToApply = ref({});
|
||||||
const ignoreWaiting = ref(false);
|
const ignoreWaiting = ref(false);
|
||||||
const isEnabled = ref(false);
|
const isEnabled = ref(false);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
const { currentAccount, updateAccount } = useAccount();
|
const { currentAccount, updateAccount } = useAccount();
|
||||||
|
|
||||||
|
const labels = useMapGetter('labels/getLabels');
|
||||||
|
|
||||||
|
const labelOptions = computed(() =>
|
||||||
|
labels.value?.length
|
||||||
|
? labels.value.map(label => ({
|
||||||
|
id: label.title,
|
||||||
|
name: label.title,
|
||||||
|
icon: h('span', {
|
||||||
|
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||||
|
style: { backgroundColor: label.color },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedLabelName = computed(() => {
|
||||||
|
return labelToApply.value?.name ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
currentAccount,
|
[currentAccount, labelOptions],
|
||||||
() => {
|
() => {
|
||||||
const {
|
const {
|
||||||
auto_resolve_after,
|
auto_resolve_after,
|
||||||
auto_resolve_message,
|
auto_resolve_message,
|
||||||
auto_resolve_ignore_waiting,
|
auto_resolve_ignore_waiting,
|
||||||
|
auto_resolve_label,
|
||||||
} = currentAccount.value?.settings || {};
|
} = currentAccount.value?.settings || {};
|
||||||
|
|
||||||
duration.value = auto_resolve_after;
|
duration.value = auto_resolve_after;
|
||||||
message.value = auto_resolve_message;
|
message.value = auto_resolve_message;
|
||||||
ignoreWaiting.value = auto_resolve_ignore_waiting;
|
ignoreWaiting.value = auto_resolve_ignore_waiting;
|
||||||
|
// find the correct label option from the list
|
||||||
|
// the single select component expects the full label object
|
||||||
|
// in our case, the label id and name are both the same
|
||||||
|
labelToApply.value = labelOptions.value.find(
|
||||||
|
option => option.name === auto_resolve_label
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set unit based on duration and its divisibility
|
||||||
|
if (duration.value) {
|
||||||
|
if (duration.value % (24 * 60) === 0) {
|
||||||
|
unit.value = DURATION_UNITS.DAYS;
|
||||||
|
} else if (duration.value % 60 === 0) {
|
||||||
|
unit.value = DURATION_UNITS.HOURS;
|
||||||
|
} else {
|
||||||
|
unit.value = DURATION_UNITS.MINUTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (duration.value) {
|
if (duration.value) {
|
||||||
isEnabled.value = true;
|
isEnabled.value = true;
|
||||||
@ -40,16 +83,19 @@ watch(
|
|||||||
|
|
||||||
const updateAccountSettings = async settings => {
|
const updateAccountSettings = async settings => {
|
||||||
try {
|
try {
|
||||||
await updateAccount(settings);
|
isSubmitting.value = true;
|
||||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS'));
|
await updateAccount(settings, { silent: true });
|
||||||
|
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR'));
|
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (duration.value < 10) {
|
if (duration.value < 10) {
|
||||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR'));
|
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +103,7 @@ const handleSubmit = async () => {
|
|||||||
auto_resolve_after: duration.value,
|
auto_resolve_after: duration.value,
|
||||||
auto_resolve_message: message.value,
|
auto_resolve_message: message.value,
|
||||||
auto_resolve_ignore_waiting: ignoreWaiting.value,
|
auto_resolve_ignore_waiting: ignoreWaiting.value,
|
||||||
|
auto_resolve_label: selectedLabelName.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -68,6 +115,7 @@ const handleDisable = async () => {
|
|||||||
auto_resolve_after: null,
|
auto_resolve_after: null,
|
||||||
auto_resolve_message: '',
|
auto_resolve_message: '',
|
||||||
auto_resolve_ignore_waiting: false,
|
auto_resolve_ignore_waiting: false,
|
||||||
|
auto_resolve_label: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -80,6 +128,7 @@ const toggleAutoResolve = async () => {
|
|||||||
<SectionLayout
|
<SectionLayout
|
||||||
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
|
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
|
||||||
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
|
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
|
||||||
|
:hide-content="!isEnabled"
|
||||||
with-border
|
with-border
|
||||||
>
|
>
|
||||||
<template #headerActions>
|
<template #headerActions>
|
||||||
@ -90,50 +139,65 @@ const toggleAutoResolve = async () => {
|
|||||||
|
|
||||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||||
<WithLabel
|
<WithLabel
|
||||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
|
||||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
|
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
|
||||||
>
|
>
|
||||||
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
|
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
|
||||||
<!-- allow 10 mins to 999 days -->
|
<!-- allow 10 mins to 999 days -->
|
||||||
<DurationInput
|
<DurationInput
|
||||||
v-model="duration"
|
v-model="duration"
|
||||||
|
v-model:unit="unit"
|
||||||
min="0"
|
min="0"
|
||||||
max="1439856"
|
max="1438560"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
<WithLabel
|
<WithLabel
|
||||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')"
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
|
||||||
:help-message="
|
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
|
||||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<TextArea
|
<TextArea
|
||||||
v-model="message"
|
v-model="message"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER')
|
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
<WithLabel
|
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
|
||||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')"
|
<div
|
||||||
>
|
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
|
||||||
<template #rightOfLabel>
|
>
|
||||||
<Switch v-model="ignoreWaiting" />
|
<div class="p-3 h-12 flex items-center justify-between">
|
||||||
</template>
|
<span>
|
||||||
<p class="text-sm ml-px text-n-slate-10 max-w-lg">
|
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
|
||||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }}
|
</span>
|
||||||
</p>
|
<Switch v-model="ignoreWaiting" />
|
||||||
|
</div>
|
||||||
|
<div class="p-3 h-12 flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<SingleSelect
|
||||||
|
v-model="labelToApply"
|
||||||
|
:options="labelOptions"
|
||||||
|
:placeholder="
|
||||||
|
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
placeholder-icon="i-lucide-chevron-down"
|
||||||
|
placeholder-trailing-icon
|
||||||
|
variant="faded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<NextButton
|
<NextButton
|
||||||
blue
|
blue
|
||||||
type="submit"
|
type="submit"
|
||||||
:label="
|
:is-loading="isSubmitting"
|
||||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON')
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,24 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
title: {
|
title: { type: String, required: true },
|
||||||
type: String,
|
description: { type: String, required: true },
|
||||||
required: true,
|
withBorder: { type: Boolean, default: false },
|
||||||
},
|
hideContent: { type: Boolean, default: false },
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
withBorder: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="grid grid-cols-1 py-8 gap-8"
|
class="grid grid-cols-1 pt-8 gap-5 [interpolate-size:allow-keywords]"
|
||||||
:class="{ 'border-t border-n-weak': withBorder }"
|
:class="{
|
||||||
|
'border-t border-n-weak': withBorder,
|
||||||
|
'pb-8': !hideContent,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<header class="grid grid-cols-4">
|
<header class="grid grid-cols-4">
|
||||||
<div class="col-span-3">
|
<div class="col-span-3">
|
||||||
@ -33,7 +28,10 @@ defineProps({
|
|||||||
<slot name="headerActions" />
|
<slot name="headerActions" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="text-n-slate-12">
|
<div
|
||||||
|
class="transition-[height] duration-300 ease-in-out text-n-slate-12"
|
||||||
|
:class="{ 'overflow-hidden h-0': hideContent, 'h-auto': !hideContent }"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const { row } = defineProps({
|
const { row } = defineProps({
|
||||||
row: {
|
row: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@ -6,10 +8,10 @@ const { row } = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const routerParams = {
|
const routerParams = computed(() => ({
|
||||||
name: 'inbox_conversation',
|
name: 'inbox_conversation',
|
||||||
params: { conversation_id: row.original.conversationId },
|
params: { conversation_id: row.original.conversationId },
|
||||||
};
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -47,14 +47,17 @@ const tableData = computed(() => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaulSpanRender = cellProps =>
|
const defaultSpanRender = cellProps => {
|
||||||
h(
|
const value = cellProps.getValue() || '---';
|
||||||
|
return h(
|
||||||
'span',
|
'span',
|
||||||
{
|
{
|
||||||
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
|
class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
|
||||||
|
title: value,
|
||||||
},
|
},
|
||||||
cellProps.getValue() ? cellProps.getValue() : '---'
|
value
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const columnHelper = createColumnHelper();
|
const columnHelper = createColumnHelper();
|
||||||
|
|
||||||
@ -65,7 +68,10 @@ const columns = [
|
|||||||
cell: cellProps => {
|
cell: cellProps => {
|
||||||
const { contact } = cellProps.row.original;
|
const { contact } = cellProps.row.original;
|
||||||
if (contact) {
|
if (contact) {
|
||||||
return h(UserAvatarWithName, { user: contact });
|
return h(UserAvatarWithName, {
|
||||||
|
user: contact,
|
||||||
|
class: 'max-w-[200px] overflow-hidden',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return '--';
|
return '--';
|
||||||
},
|
},
|
||||||
@ -76,7 +82,10 @@ const columns = [
|
|||||||
cell: cellProps => {
|
cell: cellProps => {
|
||||||
const { assignedAgent } = cellProps.row.original;
|
const { assignedAgent } = cellProps.row.original;
|
||||||
if (assignedAgent) {
|
if (assignedAgent) {
|
||||||
return h(UserAvatarWithName, { user: assignedAgent });
|
return h(UserAvatarWithName, {
|
||||||
|
user: assignedAgent,
|
||||||
|
class: 'max-w-[200px] overflow-hidden',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return '--';
|
return '--';
|
||||||
},
|
},
|
||||||
@ -105,7 +114,7 @@ const columns = [
|
|||||||
columnHelper.accessor('feedbackText', {
|
columnHelper.accessor('feedbackText', {
|
||||||
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
|
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
|
||||||
width: 400,
|
width: 400,
|
||||||
cell: defaulSpanRender,
|
cell: defaultSpanRender,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor('conversationId', {
|
columnHelper.accessor('conversationId', {
|
||||||
header: '',
|
header: '',
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="reports--wrapper overflow-auto bg-n-background w-full px-6">
|
||||||
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
|
|
||||||
>
|
|
||||||
<div class="max-w-[60rem] mx-auto pb-12">
|
<div class="max-w-[60rem] mx-auto pb-12">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,9 +6,9 @@ export default createStore({
|
|||||||
API: CopilotMessagesAPI,
|
API: CopilotMessagesAPI,
|
||||||
getters: {
|
getters: {
|
||||||
getMessagesByThreadId: state => copilotThreadId => {
|
getMessagesByThreadId: state => copilotThreadId => {
|
||||||
return state.records.filter(
|
return state.records
|
||||||
record => record.copilot_thread?.id === Number(copilotThreadId)
|
.filter(record => record.copilot_thread?.id === Number(copilotThreadId))
|
||||||
);
|
.sort((a, b) => a.id - b.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: mutationTypes => ({
|
actions: mutationTypes => ({
|
||||||
|
|||||||
@ -63,8 +63,11 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update: async ({ commit }, updateObj) => {
|
update: async ({ commit }, { options, ...updateObj }) => {
|
||||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
if (options?.silent !== true) {
|
||||||
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await AccountAPI.update('', updateObj);
|
const response = await AccountAPI.update('', updateObj);
|
||||||
commit(types.default.EDIT_ACCOUNT, response.data);
|
commit(types.default.EDIT_ACCOUNT, response.data);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ class Conversations::ResolutionJob < ApplicationJob
|
|||||||
# send message from bot that conversation has been resolved
|
# send message from bot that conversation has been resolved
|
||||||
# do this is account.auto_resolve_message is set
|
# do this is account.auto_resolve_message is set
|
||||||
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
|
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
|
||||||
|
conversation.add_labels(account.auto_resolve_label) if account.auto_resolve_label.present?
|
||||||
conversation.toggle_status
|
conversation.toggle_status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -35,7 +35,8 @@ class Account < ApplicationRecord
|
|||||||
{
|
{
|
||||||
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
|
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
|
||||||
'auto_resolve_message': { 'type': %w[string null] },
|
'auto_resolve_message': { 'type': %w[string null] },
|
||||||
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }
|
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
||||||
|
'auto_resolve_label': { 'type': %w[string null] }
|
||||||
},
|
},
|
||||||
'required': [],
|
'required': [],
|
||||||
'additionalProperties': true
|
'additionalProperties': true
|
||||||
@ -51,7 +52,7 @@ class Account < ApplicationRecord
|
|||||||
schema: SETTINGS_PARAMS_SCHEMA,
|
schema: SETTINGS_PARAMS_SCHEMA,
|
||||||
attribute_resolver: ->(record) { record.settings }
|
attribute_resolver: ->(record) { record.settings }
|
||||||
|
|
||||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label
|
||||||
|
|
||||||
has_many :account_users, dependent: :destroy_async
|
has_many :account_users, dependent: :destroy_async
|
||||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||||
|
|||||||
@ -76,18 +76,24 @@ class Article < ApplicationRecord
|
|||||||
scope :order_by_views, -> { reorder(views: :desc) }
|
scope :order_by_views, -> { reorder(views: :desc) }
|
||||||
|
|
||||||
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
|
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
|
||||||
|
# - the A, B and C are for weightage. See: https://github.com/Casecommons/pg_search#weighting
|
||||||
|
# - the normalization is for ensuring the long articles that mention the search term too many times are not ranked higher.
|
||||||
|
# it divides rank by log(document_length) to prevent longer articles from ranking higher just due to sizeSee: https://github.com/Casecommons/pg_search#normalization
|
||||||
|
# - the ranking is to ensure that articles with higher weightage are ranked higher
|
||||||
pg_search_scope(
|
pg_search_scope(
|
||||||
:text_search,
|
:text_search,
|
||||||
against: %i[
|
against: {
|
||||||
title
|
title: 'A',
|
||||||
description
|
description: 'B',
|
||||||
content
|
content: 'C'
|
||||||
],
|
},
|
||||||
using: {
|
using: {
|
||||||
tsearch: {
|
tsearch: {
|
||||||
prefix: true
|
prefix: true,
|
||||||
|
normalization: 2
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
ranked_by: ':tsearch'
|
||||||
)
|
)
|
||||||
|
|
||||||
def self.search(params)
|
def self.search(params)
|
||||||
|
|||||||
@ -96,7 +96,6 @@ class SearchService
|
|||||||
def filter_articles
|
def filter_articles
|
||||||
@articles = current_account.articles
|
@articles = current_account.articles
|
||||||
.text_search(search_query)
|
.text_search(search_query)
|
||||||
.reorder('updated_at DESC')
|
|
||||||
.page(params[:page])
|
.page(params[:page])
|
||||||
.per(15)
|
.per(15)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
if Rails.env.development?
|
if Rails.env.development? && ENV['DISABLE_MINI_PROFILER'].blank?
|
||||||
require 'rack-mini-profiler'
|
require 'rack-mini-profiler'
|
||||||
|
|
||||||
# initialization is skipped so trigger it
|
# initialization is skipped so trigger it
|
||||||
|
|||||||
@ -259,6 +259,11 @@ en:
|
|||||||
captain:
|
captain:
|
||||||
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
||||||
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
|
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
|
||||||
|
copilot:
|
||||||
|
using_tool: 'Using tool %{function_name}'
|
||||||
|
completed_tool_call: 'Completed %{function_name} tool call'
|
||||||
|
invalid_tool_call: 'Invalid tool call'
|
||||||
|
tool_not_available: 'Tool not available'
|
||||||
public_portal:
|
public_portal:
|
||||||
search:
|
search:
|
||||||
search_placeholder: Search for article by title or body...
|
search_placeholder: Search for article by title or body...
|
||||||
|
|||||||
@ -128,7 +128,6 @@ Rails.application.routes.draw do
|
|||||||
post :unread
|
post :unread
|
||||||
post :custom_attributes
|
post :custom_attributes
|
||||||
get :attachments
|
get :attachments
|
||||||
post :copilot
|
|
||||||
get :inbox_assistant
|
get :inbox_assistant
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
## Chatwoot Developer Documentation
|
|
||||||
|
|
||||||
Welcome to the official Chatwoot developer documentation. This guide contains everything you need to know about Chatwoot APIs and build custom flows on top of Chatwoot APIs.
|
|
||||||
|
|
||||||
### 👩💻 Development
|
|
||||||
|
|
||||||
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
|
|
||||||
|
|
||||||
```
|
|
||||||
npm i -g mintlify
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the following command at the root of your documentation (where mint.json is)
|
|
||||||
|
|
||||||
```
|
|
||||||
mintlify dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 😎 Publishing Changes
|
|
||||||
|
|
||||||
Changes will be deployed to production automatically after pushing to the default branch.
|
|
||||||
|
|
||||||
You can also preview changes using PRs, which generates a preview link of the docs.
|
|
||||||
|
|
||||||
#### Troubleshooting
|
|
||||||
|
|
||||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
|
||||||
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://mintlify.com/docs.json",
|
|
||||||
"name": "Chatwoot Developer Docs",
|
|
||||||
"description": "Official developer documentation for Chatwoot - the open-source customer support platform. Learn about our APIs, integrations, and development guidelines.",
|
|
||||||
"logo": {
|
|
||||||
"dark": "/logo/dark.png",
|
|
||||||
"light": "/logo/light.png"
|
|
||||||
},
|
|
||||||
"favicon": "/favicon.png",
|
|
||||||
"colors": {
|
|
||||||
"primary": "#0069ED",
|
|
||||||
"light": "#4D9CFF",
|
|
||||||
"dark": "#0050B4"
|
|
||||||
},
|
|
||||||
"fonts": {
|
|
||||||
"heading": {
|
|
||||||
"family": "Haskoy",
|
|
||||||
"weight": 500,
|
|
||||||
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Medium.woff2",
|
|
||||||
"format": "woff2"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"family": "Haskoy",
|
|
||||||
"weight": 400,
|
|
||||||
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Regular.woff2",
|
|
||||||
"format": "woff2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"theme": "maple",
|
|
||||||
"navigation": {
|
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"group": "API Reference",
|
|
||||||
"pages": [
|
|
||||||
"introduction"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "Application API",
|
|
||||||
"description": "APIs for managing application aspects of Chatwoot",
|
|
||||||
"includeTags": [
|
|
||||||
"Agents",
|
|
||||||
"Automation Rule",
|
|
||||||
"Account AgentBots",
|
|
||||||
"Canned Responses",
|
|
||||||
"Contact Labels",
|
|
||||||
"Contacts",
|
|
||||||
"Conversation Assignments",
|
|
||||||
"Conversation Labels",
|
|
||||||
"Conversations",
|
|
||||||
"Custom Attributes",
|
|
||||||
"Custom Filters",
|
|
||||||
"Help Center",
|
|
||||||
"Inboxes",
|
|
||||||
"Integrations",
|
|
||||||
"Messages",
|
|
||||||
"Profile",
|
|
||||||
"Reports",
|
|
||||||
"Teams",
|
|
||||||
"Webhooks"
|
|
||||||
],
|
|
||||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/application_swagger.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "Platform API",
|
|
||||||
"description": "APIs for managing platform aspects of Chatwoot",
|
|
||||||
"includeTags": [
|
|
||||||
"Accounts",
|
|
||||||
"Account Users",
|
|
||||||
"AgentBots",
|
|
||||||
"Users"
|
|
||||||
],
|
|
||||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/platform_swagger.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "Client API",
|
|
||||||
"description": "APIs for client applications",
|
|
||||||
"includeTags": [
|
|
||||||
"Contacts API",
|
|
||||||
"Conversations API",
|
|
||||||
"Messages API"
|
|
||||||
],
|
|
||||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/client_swagger.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "Other APIs",
|
|
||||||
"description": "Other Chatwoot APIs",
|
|
||||||
"includeTags": [
|
|
||||||
"CSAT Survey Page"
|
|
||||||
],
|
|
||||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/other_swagger.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"footerSocials": {
|
|
||||||
"twitter": "https://twitter.com/chatwootapp",
|
|
||||||
"github": "https://github.com/chatwoot",
|
|
||||||
"linkedin": "https://www.linkedin.com/company/chatwoot"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
title: Introduction to Chatwoot APIs
|
|
||||||
description: Learn how to use Chatwoot APIs to build integrations, customize chat experiences, and manage your installation.
|
|
||||||
sidebarTitle: Introduction
|
|
||||||
---
|
|
||||||
|
|
||||||
Welcome to the Chatwoot API documentation. Whether you're building custom workflows for your support team, integrating Chatwoot into your product, or managing users across installations, our APIs provide the flexibility and power to help you do more with Chatwoot.
|
|
||||||
|
|
||||||
Chatwoot provides three categories of APIs, each designed with a specific use case in mind:
|
|
||||||
|
|
||||||
- **Application APIs** – For account-level automation and agent-facing integrations.
|
|
||||||
- **Client APIs** – For building custom chat interfaces for end-users
|
|
||||||
- **Platform APIs** – For managing and administering installations at scale
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Application APIs
|
|
||||||
|
|
||||||
Application APIs are designed for interacting with a Chatwoot account from an agent/admin perspective. Use them to build internal tools, automate workflows, or perform bulk operations like data import/export.
|
|
||||||
|
|
||||||
- **Authentication**: Requires a user `access_token`, which can be generated from **Profile Settings** after logging into your Chatwoot account.
|
|
||||||
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
|
|
||||||
- **Example**: [Google Cloud Functions Demo](https://github.com/chatwoot/google-cloud-functions-demo)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Client APIs
|
|
||||||
|
|
||||||
Client APIs are intended for building custom messaging experiences over Chatwoot. If you're not using the native website widget or want to embed chat in your mobile app, these APIs are the way to go.
|
|
||||||
|
|
||||||
- **Authentication**: Uses `inbox_identifier` (from **Settings → Configuration** in API inboxes) and `contact_identifier` (returned when creating a contact).
|
|
||||||
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
|
|
||||||
- **Examples**:
|
|
||||||
|
|
||||||
- [Client API Demo](https://github.com/chatwoot/client-api-demo)
|
|
||||||
- [Flutter SDK](https://github.com/chatwoot/chatwoot-flutter-sdk)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Platform APIs
|
|
||||||
|
|
||||||
Platform APIs are used to manage Chatwoot installations at the admin level. These APIs allow you to control users, roles, and accounts, or sync data from external authentication systems.
|
|
||||||
|
|
||||||
- **Authentication**: Requires an `access_token` generated by a **Platform App**, which can be created in the **Super Admin Console**.
|
|
||||||
- **Availability**: Available on **Self-hosted** / **Managed Hosting** Chatwoot installations only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Use the right API for your use case, and you'll be able to extend, customize, and integrate Chatwoot into your stack with ease.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
@ -1,31 +1,5 @@
|
|||||||
module Enterprise::Api::V1::Accounts::ConversationsController
|
module Enterprise::Api::V1::Accounts::ConversationsController
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
included do
|
|
||||||
before_action :set_assistant, only: [:copilot]
|
|
||||||
end
|
|
||||||
|
|
||||||
def copilot
|
|
||||||
# First try to get the user's preferred assistant from UI settings or from the request
|
|
||||||
assistant_id = copilot_params[:assistant_id] || current_user.ui_settings&.dig('preferred_captain_assistant_id')
|
|
||||||
|
|
||||||
# Find the assistant either by ID or from inbox
|
|
||||||
assistant = if assistant_id.present?
|
|
||||||
Captain::Assistant.find_by(id: assistant_id, account_id: Current.account.id)
|
|
||||||
else
|
|
||||||
@conversation.inbox.captain_assistant
|
|
||||||
end
|
|
||||||
|
|
||||||
return render json: { message: I18n.t('captain.copilot_error') } unless assistant
|
|
||||||
|
|
||||||
response = Captain::Copilot::ChatService.new(
|
|
||||||
assistant,
|
|
||||||
previous_history: copilot_params[:previous_history],
|
|
||||||
conversation_id: @conversation.display_id,
|
|
||||||
user_id: Current.user.id
|
|
||||||
).generate_response(copilot_params[:message])
|
|
||||||
|
|
||||||
render json: { message: response['response'] }
|
|
||||||
end
|
|
||||||
|
|
||||||
def inbox_assistant
|
def inbox_assistant
|
||||||
assistant = @conversation.inbox.captain_assistant
|
assistant = @conversation.inbox.captain_assistant
|
||||||
|
|||||||
@ -53,9 +53,21 @@ module Captain::ChatHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def execute_tool(function_name, arguments, tool_call_id)
|
def execute_tool(function_name, arguments, tool_call_id)
|
||||||
persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking')
|
persist_message(
|
||||||
|
{
|
||||||
|
content: I18n.t('captain.copilot.using_tool', function_name: function_name),
|
||||||
|
function_name: function_name
|
||||||
|
},
|
||||||
|
'assistant_thinking'
|
||||||
|
)
|
||||||
result = @tool_registry.send(function_name, arguments)
|
result = @tool_registry.send(function_name, arguments)
|
||||||
persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking')
|
persist_message(
|
||||||
|
{
|
||||||
|
content: I18n.t('captain.copilot.completed_tool_call', function_name: function_name),
|
||||||
|
function_name: function_name
|
||||||
|
},
|
||||||
|
'assistant_thinking'
|
||||||
|
)
|
||||||
append_tool_response(result, tool_call_id)
|
append_tool_response(result, tool_call_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -67,8 +79,8 @@ module Captain::ChatHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def process_invalid_tool_call(function_name, tool_call_id)
|
def process_invalid_tool_call(function_name, tool_call_id)
|
||||||
persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking')
|
persist_message({ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, 'assistant_thinking')
|
||||||
append_tool_response('Tool not available', tool_call_id)
|
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def append_tool_response(content, tool_call_id)
|
def append_tool_response(content, tool_call_id)
|
||||||
|
|||||||
@ -60,7 +60,7 @@ class CopilotMessage < ApplicationRecord
|
|||||||
def validate_message_attributes
|
def validate_message_attributes
|
||||||
return if message.blank?
|
return if message.blank?
|
||||||
|
|
||||||
allowed_keys = %w[content reasoning function_name]
|
allowed_keys = %w[content reasoning function_name reply_suggestion]
|
||||||
invalid_keys = message.keys - allowed_keys
|
invalid_keys = message.keys - allowed_keys
|
||||||
|
|
||||||
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
|
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
|
||||||
|
|||||||
@ -74,7 +74,10 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
|||||||
def system_message
|
def system_message
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
|
content: Captain::Llm::SystemPromptsService.copilot_response_generator(
|
||||||
|
@assistant.config['product_name'],
|
||||||
|
@tool_registry.tools_summary
|
||||||
|
)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class Captain::Llm::SystemPromptsService
|
|||||||
SYSTEM_PROMPT_MESSAGE
|
SYSTEM_PROMPT_MESSAGE
|
||||||
end
|
end
|
||||||
|
|
||||||
def copilot_response_generator(product_name)
|
def copilot_response_generator(product_name, available_tools)
|
||||||
<<~SYSTEM_PROMPT_MESSAGE
|
<<~SYSTEM_PROMPT_MESSAGE
|
||||||
[Identity]
|
[Identity]
|
||||||
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
|
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
|
||||||
@ -94,12 +94,20 @@ class Captain::Llm::SystemPromptsService
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"reasoning": "Explain why the response was chosen based on the provided information.",
|
"reasoning": "Explain why the response was chosen based on the provided information.",
|
||||||
"response": "Provide the answer only in Markdown format for readability."
|
"content": "Provide the answer only in Markdown format for readability.",
|
||||||
|
"reply_suggestion": "A boolean value that is true only if the support agent has explicitly asked to draft a response to the customer, and the response fulfills that request. Otherwise, it should be false."
|
||||||
}
|
}
|
||||||
|
|
||||||
[Error Handling]
|
[Error Handling]
|
||||||
- If the required information is not found in the provided context, respond with an appropriate message indicating that no relevant data is available.
|
- If the required information is not found in the provided context, respond with an appropriate message indicating that no relevant data is available.
|
||||||
- Avoid speculating or providing unverified information.
|
- Avoid speculating or providing unverified information.
|
||||||
|
|
||||||
|
[Available Actions]
|
||||||
|
You have the following actions available to assist support agents:
|
||||||
|
- summarize_conversation: Summarize the conversation
|
||||||
|
- draft_response: Draft a response for the support agent
|
||||||
|
- rate_conversation: Rate the conversation
|
||||||
|
#{available_tools}
|
||||||
SYSTEM_PROMPT_MESSAGE
|
SYSTEM_PROMPT_MESSAGE
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -27,4 +27,10 @@ class Captain::ToolRegistryService
|
|||||||
def respond_to_missing?(method_name, include_private = false)
|
def respond_to_missing?(method_name, include_private = false)
|
||||||
@tools.key?(method_name.to_s) || super
|
@tools.key?(method_name.to_s) || super
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def tools_summary
|
||||||
|
@tools.map do |name, tool|
|
||||||
|
"- #{name}: #{tool.description}"
|
||||||
|
end.join("\n")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -19,8 +19,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
|||||||
status = arguments['status']
|
status = arguments['status']
|
||||||
contact_id = arguments['contact_id']
|
contact_id = arguments['contact_id']
|
||||||
priority = arguments['priority']
|
priority = arguments['priority']
|
||||||
|
labels = arguments['labels']
|
||||||
|
|
||||||
conversations = get_conversations(status, contact_id, priority)
|
conversations = get_conversations(status, contact_id, priority, labels)
|
||||||
|
|
||||||
return 'No conversations found' unless conversations.exists?
|
return 'No conversations found' unless conversations.exists?
|
||||||
|
|
||||||
@ -41,11 +42,12 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def get_conversations(status, contact_id, priority)
|
def get_conversations(status, contact_id, priority, labels)
|
||||||
conversations = permissible_conversations
|
conversations = permissible_conversations
|
||||||
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
|
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
|
||||||
conversations = conversations.where(status: status) if status.present?
|
conversations = conversations.where(status: status) if status.present?
|
||||||
conversations = conversations.where(priority: priority) if priority.present?
|
conversations = conversations.where(priority: priority) if priority.present?
|
||||||
|
conversations = conversations.tagged_with(labels, any: true) if labels.present?
|
||||||
conversations
|
conversations
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -59,20 +61,10 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
|||||||
|
|
||||||
def properties
|
def properties
|
||||||
{
|
{
|
||||||
contact_id: {
|
contact_id: { type: 'number', description: 'Filter conversations by contact ID' },
|
||||||
type: 'number',
|
status: { type: 'string', enum: %w[open resolved pending snoozed], description: 'Filter conversations by status' },
|
||||||
description: 'Filter conversations by contact ID'
|
priority: { type: 'string', enum: %w[low medium high urgent], description: 'Filter conversations by priority' },
|
||||||
},
|
labels: { type: 'array', items: { type: 'string' }, description: 'Filter conversations by labels' }
|
||||||
status: {
|
|
||||||
type: 'string',
|
|
||||||
enum: %w[open resolved pending snoozed],
|
|
||||||
description: 'Filter conversations by status'
|
|
||||||
},
|
|
||||||
priority: {
|
|
||||||
type: 'string',
|
|
||||||
enum: %w[low medium high urgent],
|
|
||||||
description: 'Filter conversations by priority'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -109,4 +109,46 @@ RSpec.describe Captain::ToolRegistryService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#tools_summary' do
|
||||||
|
let(:tool_class) { TestTool }
|
||||||
|
|
||||||
|
before do
|
||||||
|
service.register_tool(tool_class)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns formatted summary of registered tools' do
|
||||||
|
expect(service.tools_summary).to eq('- test_tool: A test tool for specs')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when multiple tools are registered' do
|
||||||
|
let(:another_tool_class) do
|
||||||
|
Class.new(Captain::Tools::BaseService) do
|
||||||
|
def name
|
||||||
|
'another_tool'
|
||||||
|
end
|
||||||
|
|
||||||
|
def description
|
||||||
|
'Another test tool'
|
||||||
|
end
|
||||||
|
|
||||||
|
def parameters
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes all tools in the summary' do
|
||||||
|
service.register_tool(another_tool_class)
|
||||||
|
expect(service.tools_summary).to eq("- test_tool: A test tool for specs\n- another_tool: Another test tool")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -4,6 +4,7 @@ RSpec.describe Conversations::ResolutionJob do
|
|||||||
subject(:job) { described_class.perform_later(account: account) }
|
subject(:job) { described_class.perform_later(account: account) }
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
|
let(:label) { create(:label, title: 'auto-resolved', account: account) }
|
||||||
let!(:conversation) { create(:conversation, account: account) }
|
let!(:conversation) { create(:conversation, account: account) }
|
||||||
|
|
||||||
it 'enqueues the job' do
|
it 'enqueues the job' do
|
||||||
@ -47,6 +48,16 @@ RSpec.describe Conversations::ResolutionJob do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'adds a label after resolution' do
|
||||||
|
account.update(auto_resolve_label: 'auto-resolved', auto_resolve_after: 14_400)
|
||||||
|
conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
|
||||||
|
|
||||||
|
described_class.perform_now(account: account)
|
||||||
|
|
||||||
|
expect(conversation.reload.status).to eq('resolved')
|
||||||
|
expect(conversation.reload.label_list).to include('auto-resolved')
|
||||||
|
end
|
||||||
|
|
||||||
it 'resolves only a limited number of conversations in a single execution' do
|
it 'resolves only a limited number of conversations in a single execution' do
|
||||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
||||||
account.update!(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
|
account.update!(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
|
||||||
|
|||||||
@ -156,33 +156,18 @@ describe SearchService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when article search' do
|
context 'when article search' do
|
||||||
it 'orders results by updated_at desc' do
|
it 'returns matching articles' do
|
||||||
# Create articles with explicit timestamps
|
|
||||||
older_time = 2.days.ago
|
|
||||||
newer_time = 1.hour.ago
|
|
||||||
|
|
||||||
article2 = create(:article, title: 'Spellcasting Guide',
|
article2 = create(:article, title: 'Spellcasting Guide',
|
||||||
account: account, portal: portal, author: user, status: 'published')
|
account: account, portal: portal, author: user, status: 'published')
|
||||||
# rubocop:disable Rails/SkipsModelValidations
|
|
||||||
article2.update_column(:updated_at, older_time)
|
|
||||||
# rubocop:enable Rails/SkipsModelValidations
|
|
||||||
|
|
||||||
article3 = create(:article, title: 'Spellcasting Manual',
|
article3 = create(:article, title: 'Spellcasting Manual',
|
||||||
account: account, portal: portal, author: user, status: 'published')
|
account: account, portal: portal, author: user, status: 'published')
|
||||||
# rubocop:disable Rails/SkipsModelValidations
|
|
||||||
article3.update_column(:updated_at, newer_time)
|
|
||||||
# rubocop:enable Rails/SkipsModelValidations
|
|
||||||
|
|
||||||
params = { q: 'Spellcasting' }
|
params = { q: 'Spellcasting' }
|
||||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||||
results = search.perform[:articles]
|
results = search.perform[:articles]
|
||||||
|
|
||||||
# Check the timestamps to understand ordering
|
|
||||||
results.map { |a| [a.id, a.updated_at] }
|
|
||||||
|
|
||||||
# Should be ordered by updated_at desc (newer first)
|
|
||||||
expect(results.length).to eq(2)
|
expect(results.length).to eq(2)
|
||||||
expect(results.first.updated_at).to be > results.second.updated_at
|
expect(results.map(&:id)).to contain_exactly(article2.id, article3.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns paginated results' do
|
it 'returns paginated results' do
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user