diff --git a/app/javascript/dashboard/api/captain/scenarios.js b/app/javascript/dashboard/api/captain/scenarios.js new file mode 100644 index 000000000..3e61c28a3 --- /dev/null +++ b/app/javascript/dashboard/api/captain/scenarios.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainScenarios extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId, page = 1, searchKey } = {}) { + return axios.get(`${this.url}/${assistantId}/scenarios`, { + params: { page, searchKey }, + }); + } + + show({ assistantId, id }) { + return axios.get(`${this.url}/${assistantId}/scenarios/${id}`); + } + + create({ assistantId, ...data } = {}) { + return axios.post(`${this.url}/${assistantId}/scenarios`, { + scenario: data, + }); + } + + update({ assistantId, id }, data = {}) { + return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, { + scenario: data, + }); + } + + delete({ assistantId, id }) { + return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`); + } +} + +export default new CaptainScenarios(); diff --git a/app/javascript/dashboard/api/captain/tools.js b/app/javascript/dashboard/api/captain/tools.js new file mode 100644 index 000000000..20edaa95e --- /dev/null +++ b/app/javascript/dashboard/api/captain/tools.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainTools extends ApiClient { + constructor() { + super('captain/assistants/tools', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { + params, + }); + } +} + +export default new CaptainTools(); diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 9e5ff6ab5..a2f139bdc 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -20,6 +20,7 @@ const props = defineProps({ enableVariables: { type: Boolean, default: false }, enableCannedResponses: { type: Boolean, default: true }, enabledMenuOptions: { type: Array, default: () => [] }, + enableCaptainTools: { type: Boolean, default: false }, }); const emit = defineEmits(['update:modelValue']); @@ -98,6 +99,7 @@ watch( :enable-variables="enableVariables" :enable-canned-responses="enableCannedResponses" :enabled-menu-options="enabledMenuOptions" + :enable-captain-tools="enableCaptainTools" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue b/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue index ecdb2d654..c1a465c64 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue @@ -1,5 +1,6 @@ - + +import { computed, reactive } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useToggle } from '@vueuse/core'; +import { useVuelidate } from '@vuelidate/core'; +import { vOnClickOutside } from '@vueuse/components'; +import { required, minLength } from '@vuelidate/validators'; + +import Input from 'dashboard/components-next/input/Input.vue'; +import Button from 'dashboard/components-next/button/Button.vue'; +import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; +import Editor from 'dashboard/components-next/Editor/Editor.vue'; + +const emit = defineEmits(['add']); + +const { t } = useI18n(); + +const [showPopover, togglePopover] = useToggle(); + +const state = reactive({ + id: '', + title: '', + description: '', + instruction: '', +}); + +const rules = { + title: { required, minLength: minLength(1) }, + description: { required }, + instruction: { required }, +}; + +const v$ = useVuelidate(rules, state); + +const titleError = computed(() => + v$.value.title.$error + ? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR') + : '' +); + +const descriptionError = computed(() => + v$.value.description.$error + ? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR') + : '' +); + +const instructionError = computed(() => + v$.value.instruction.$error + ? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR') + : '' +); + +const resetState = () => { + Object.assign(state, { + id: '', + title: '', + description: '', + instruction: '', + }); +}; + +const onClickAdd = async () => { + v$.value.$touch(); + if (v$.value.$invalid) return; + + await emit('add', state); + resetState(); + togglePopover(false); +}; + +const onClickCancel = () => { + togglePopover(false); +}; + + + + + + + + + {{ t(`CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.TITLE`) }} + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/assistant/RuleCard.vue b/app/javascript/dashboard/components-next/captain/assistant/RuleCard.vue index c74881988..f2ffcc983 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/RuleCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/RuleCard.vue @@ -73,7 +73,6 @@ const saveEdit = () => { v-if="isEditing" v-model="editedContent" focus-on-mount - custom-input-class="flex items-center gap-2 text-sm text-n-slate-12" @keyup.enter="saveEdit" /> diff --git a/app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.story.vue b/app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.story.vue new file mode 100644 index 000000000..3da548718 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.story.vue @@ -0,0 +1,45 @@ + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.vue b/app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.vue new file mode 100644 index 000000000..d05960772 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.vue @@ -0,0 +1,218 @@ + + + + + + + + + + + + {{ title }} + + {{ description }} + + + + + + + + + + + + {{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }} + {{ tools?.map(tool => `@${tool}`).join(', ') }} + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.story.vue b/app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.story.vue new file mode 100644 index 000000000..7d0d90393 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.story.vue @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.vue b/app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.vue new file mode 100644 index 000000000..98f706384 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.vue @@ -0,0 +1,54 @@ + + + + + + {{ tool.title }} + {{ tool.description }} + + + diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 7ac589691..b76416c2d 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -15,6 +15,7 @@ import CannedResponse from '../conversation/CannedResponse.vue'; import KeyboardEmojiSelector from './keyboardEmojiSelector.vue'; import TagAgents from '../conversation/TagAgents.vue'; import VariableList from '../conversation/VariableList.vue'; +import TagTools from '../conversation/TagTools.vue'; import { useEmitter } from 'dashboard/composables/emitter'; import { useI18n } from 'vue-i18n'; @@ -72,6 +73,7 @@ const props = defineProps({ updateSelectionWith: { type: String, default: '' }, enableVariables: { type: Boolean, default: false }, enableCannedResponses: { type: Boolean, default: true }, + enableCaptainTools: { type: Boolean, default: false }, variables: { type: Object, default: () => ({}) }, enabledMenuOptions: { type: Array, default: () => [] }, signature: { type: String, default: '' }, @@ -89,6 +91,7 @@ const emit = defineEmits([ 'toggleUserMention', 'toggleCannedMenu', 'toggleVariablesMenu', + 'toggleToolsMenu', 'clearSelection', 'blur', 'focus', @@ -140,7 +143,9 @@ const showUserMentions = ref(false); const showCannedMenu = ref(false); const showVariables = ref(false); const showEmojiMenu = ref(false); +const showToolsMenu = ref(false); const mentionSearchKey = ref(''); +const toolSearchKey = ref(''); const cannedSearchTerm = ref(''); const variableSearchTerm = ref(''); const emojiSearchTerm = ref(''); @@ -216,11 +221,17 @@ const plugins = computed(() => { } return [ + createSuggestionPlugin({ + trigger: '@', + showMenu: showToolsMenu, + searchTerm: toolSearchKey, + isAllowed: () => props.enableCaptainTools, + }), createSuggestionPlugin({ trigger: '@', showMenu: showUserMentions, searchTerm: mentionSearchKey, - isAllowed: () => props.isPrivate, + isAllowed: () => props.isPrivate || !props.enableCaptainTools, }), createSuggestionPlugin({ trigger: '/', @@ -262,6 +273,9 @@ watch(showCannedMenu, updatedValue => { watch(showVariables, updatedValue => { emit('toggleVariablesMenu', !props.isPrivate && updatedValue); }); +watch(showToolsMenu, updatedValue => { + emit('toggleToolsMenu', props.enableCaptainTools && updatedValue); +}); function focusEditorInputField(pos = 'end') { const { tr } = editorView.state; @@ -538,6 +552,7 @@ function insertSpecialContent(type, content) { cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE, variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE, emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI, + tool: CONVERSATION_EVENTS.INSERTED_A_TOOL, }; useTrack(event_map[type]); @@ -699,6 +714,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); :search-key="emojiSearchTerm" @select-emoji="emoji => insertSpecialContent('emoji', emoji)" /> + insertSpecialContent('tool', content)" + /> +import { ref, computed, watch } from 'vue'; +import ToolsDropdown from 'dashboard/components-next/captain/assistant/ToolsDropdown.vue'; +import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList'; +import { useMapGetter } from 'dashboard/composables/store.js'; + +const props = defineProps({ + searchKey: { + type: String, + default: '', + }, +}); + +const emit = defineEmits(['selectTool']); + +const tools = useMapGetter('captainTools/getRecords'); + +const selectedIndex = ref(0); + +const filteredTools = computed(() => { + const search = props.searchKey?.trim().toLowerCase() || ''; + + return tools.value.filter(tool => tool.title.toLowerCase().includes(search)); +}); + +const adjustScroll = () => {}; + +const onSelect = idx => { + if (idx) selectedIndex.value = idx; + emit('selectTool', filteredTools.value[selectedIndex.value]); +}; + +useKeyboardNavigableList({ + items: filteredTools, + onSelect, + adjustScroll, + selectedIndex, +}); + +watch(filteredTools, newListOfTools => { + if (newListOfTools.length < selectedIndex.value + 1) { + selectedIndex.value = 0; + } +}); + + + + + + diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 64dfb89e5..727316f1d 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -6,6 +6,7 @@ export const CONVERSATION_EVENTS = Object.freeze({ TRANSLATE_A_MESSAGE: 'Translated a message', INSERTED_A_VARIABLE: 'Inserted a variable', INSERTED_AN_EMOJI: 'Inserted an emoji', + INSERTED_A_TOOL: 'Inserted a tool', USED_MENTIONS: 'Used mentions', SEARCH_CONVERSATION: 'Searched conversations', APPLY_FILTER: 'Applied filters in the conversation list', diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 1fede3a58..ae4c4a8cc 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -319,6 +319,12 @@ const createNode = (editorView, nodeType, content) => { return state.schema.text(`{{${content}}}`); case 'emoji': return state.schema.text(content); + case 'tool': { + return state.schema.nodes.tools.create({ + id: content.id, + name: content.title, + }); + } default: return null; } @@ -355,6 +361,11 @@ const nodeCreators = { from, to, }), + tool: (editorView, content, from, to) => ({ + node: createNode(editorView, 'tool', content), + from, + to, + }), }; /** diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index d7bf575de..082d12e56 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -554,6 +554,7 @@ "SEARCH_PLACEHOLDER": "Search..." }, "EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.", + "SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.", "API": { "ADD": { "SUCCESS": "Guardrails added successfully", @@ -601,6 +602,7 @@ "SEARCH_PLACEHOLDER": "Search..." }, "EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.", + "SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.", "API": { "ADD": { "SUCCESS": "Response Guidelines added successfully", @@ -615,6 +617,73 @@ "ERROR": "There was an error deleting response guidelines, please try again." } } + }, + "SCENARIOS": { + "TITLE": "Scenarios", + "DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”", + "BREADCRUMB": { + "TITLE": "Scenarios" + }, + "BULK_ACTION": { + "SELECTED": "{count} item selected | {count} items selected", + "SELECT_ALL": "Select all ({count})", + "UNSELECT_ALL": "Unselect all ({count})", + "BULK_DELETE_BUTTON": "Delete" + }, + "ADD": { + "SUGGESTED": { + "TITLE": "Example scenarios", + "ADD": "Add all", + "ADD_SINGLE": "Add this", + "TOOLS_USED": "Tools used :" + }, + "NEW": { + "CREATE": "Add a scenario", + "TITLE": "Create a scenario", + "FORM": { + "TITLE": { + "LABEL": "Title", + "PLACEHOLDER": "Enter a name for the scenario", + "ERROR": "Scenario name is required" + }, + "DESCRIPTION": { + "LABEL": "Description", + "PLACEHOLDER": "Describe how and where this scenario will be used", + "ERROR": "Scenario description is required" + }, + "INSTRUCTION": { + "LABEL": "How to handle", + "PLACEHOLDER": "Describe how and where this scenario will be handled", + "ERROR": "Scenario content is required" + }, + "CREATE": "Create", + "CANCEL": "Cancel" + } + } + }, + "UPDATE": { + "CANCEL": "Cancel", + "UPDATE": "Update changes" + }, + "LIST": { + "SEARCH_PLACEHOLDER": "Search..." + }, + "EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.", + "SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.", + "API": { + "ADD": { + "SUCCESS": "Scenarios added successfully", + "ERROR": "There was an error adding scenarios, please try again." + }, + "UPDATE": { + "SUCCESS": "Scenarios updated successfully", + "ERROR": "There was an error updating scenarios, please try again." + }, + "DELETE": { + "SUCCESS": "Scenarios deleted successfully", + "ERROR": "There was an error deleting scenarios, please try again." + } + } } }, "DOCUMENTS": { diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/guardrails/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/guardrails/Index.vue index 0adbc0d13..670a40dc3 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/assistants/guardrails/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/guardrails/Index.vue @@ -266,6 +266,11 @@ const addAllExample = () => { {{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.EMPTY_MESSAGE') }} + + + {{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.SEARCH_EMPTY_MESSAGE') }} + + { {{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }} + + + {{ + t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.SEARCH_EMPTY_MESSAGE') + }} + + +import { computed, h, ref, onMounted } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRoute } from 'vue-router'; +import { picoSearch } from '@scmmishra/pico-search'; +import { useStore, useMapGetter } from 'dashboard/composables/store'; +import { useAlert } from 'dashboard/composables'; +import { useUISettings } from 'dashboard/composables/useUISettings'; +import Button from 'dashboard/components-next/button/Button.vue'; +import Input from 'dashboard/components-next/input/Input.vue'; + +import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue'; +import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue'; +import SuggestedScenarios from 'dashboard/components-next/captain/assistant/SuggestedRules.vue'; +import ScenariosCard from 'dashboard/components-next/captain/assistant/ScenariosCard.vue'; +import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue'; +import AddNewScenariosDialog from 'dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue'; + +const { t } = useI18n(); +const route = useRoute(); +const store = useStore(); +const { uiSettings, updateUISettings } = useUISettings(); +const assistantId = route.params.assistantId; + +const uiFlags = useMapGetter('captainScenarios/getUIFlags'); +const isFetching = computed(() => uiFlags.value.fetchingList); +const assistant = computed(() => + store.getters['captainAssistants/getRecord'](Number(assistantId)) +); +const scenarios = useMapGetter('captainScenarios/getRecords'); + +const searchQuery = ref(''); + +const breadcrumbItems = computed(() => { + return [ + { + label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'), + routeName: 'captain_assistants_index', + }, + { label: assistant.value?.name, routeName: 'captain_assistants_edit' }, + { label: t('CAPTAIN.ASSISTANTS.SCENARIOS.BREADCRUMB.TITLE') }, + ]; +}); + +const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g; + +const renderInstruction = instruction => () => + h('span', { + class: 'text-sm text-n-slate-12 py-4', + innerHTML: instruction.replace( + TOOL_LINK_REGEX, + (_, title) => + `@${title.replace(/^@/, '')}` + ), + }); + +// Suggested example scenarios for quick add +const scenariosExample = [ + { + id: 1, + title: 'Refund Order', + description: 'User encountered a technical issue or error message.', + instruction: + 'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if it’s a known bug. File with [Create Bug Report](tool://bug_report_create) if new.', + tools: ['create_bug_report', 'known_issues'], + }, + { + id: 2, + title: 'Product Recommendation', + description: 'User is unsure which product or service to choose.', + instruction: + 'Ask 2–3 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 2–3 options with pros/cons. Link to compare page if available.', + tools: ['product_match[user_needs]'], + }, +]; + +const filteredScenarios = computed(() => { + const query = searchQuery.value.trim(); + const source = scenarios.value; + if (!query) return source; + return picoSearch(source, query, ['title', 'description', 'instruction']); +}); + +const shouldShowSuggestedRules = computed(() => { + return uiSettings.value?.show_scenarios_suggestions !== false; +}); + +const closeSuggestedRules = () => { + updateUISettings({ show_scenarios_suggestions: false }); +}; + +// Bulk selection & hover state +const bulkSelectedIds = ref(new Set()); +const hoveredCard = ref(null); + +const handleRuleSelect = id => { + const selected = new Set(bulkSelectedIds.value); + selected[selected.has(id) ? 'delete' : 'add'](id); + bulkSelectedIds.value = selected; +}; + +const buildSelectedCountLabel = computed(() => { + const count = scenarios.value.length || 0; + const isAllSelected = bulkSelectedIds.value.size === count && count > 0; + return isAllSelected + ? t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.UNSELECT_ALL', { count }) + : t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.SELECT_ALL', { count }); +}); + +const selectedCountLabel = computed(() => { + return t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.SELECTED', { + count: bulkSelectedIds.value.size, + }); +}); + +const handleRuleHover = (isHovered, id) => { + hoveredCard.value = isHovered ? id : null; +}; + +const getToolsFromInstruction = instruction => [ + ...new Set( + [...(instruction?.matchAll(/\(tool:\/\/([^)]+)\)/g) ?? [])].map(m => m[1]) + ), +]; + +const updateScenario = async scenario => { + try { + await store.dispatch('captainScenarios/update', { + id: scenario.id, + assistantId: route.params.assistantId, + ...scenario, + tools: getToolsFromInstruction(scenario.instruction), + }); + useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.SUCCESS')); + } catch (error) { + const errorMessage = + error?.response?.message || + t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.ERROR'); + useAlert(errorMessage); + } +}; + +const deleteScenario = async id => { + try { + await store.dispatch('captainScenarios/delete', { + id, + assistantId: route.params.assistantId, + }); + useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS')); + } catch (error) { + const errorMessage = + error?.response?.message || + t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.ERROR'); + useAlert(errorMessage); + } +}; + +// TODO: Add bulk delete endpoint +const bulkDeleteScenarios = async ids => { + const idsArray = ids || Array.from(bulkSelectedIds.value); + await Promise.all( + idsArray.map(id => + store.dispatch('captainScenarios/delete', { + id, + assistantId: route.params.assistantId, + }) + ) + ); + bulkSelectedIds.value = new Set(); + useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS')); +}; + +const addScenario = async scenario => { + try { + await store.dispatch('captainScenarios/create', { + assistantId: route.params.assistantId, + ...scenario, + tools: getToolsFromInstruction(scenario.instruction), + }); + useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS')); + } catch (error) { + const errorMessage = + error?.response?.message || + t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.ERROR'); + useAlert(errorMessage); + } +}; + +const addAllExampleScenarios = async () => { + try { + scenariosExample.forEach(async scenario => { + await store.dispatch('captainScenarios/create', { + assistantId: route.params.assistantId, + ...scenario, + }); + }); + useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS')); + } catch (error) { + const errorMessage = + error?.response?.message || + t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.ERROR'); + useAlert(errorMessage); + } +}; + +onMounted(() => { + store.dispatch('captainScenarios/get', { + assistantId: assistantId, + }); + store.dispatch('captainTools/getTools'); +}); + + + + + + + + + + + + {{ item.title }} + + + + + + {{ item.description }} + + + + {{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }} + {{ item.tools?.map(tool => `@${tool}`).join(', ') }} + + + + + + + + + + + + + + + + + + + {{ t('CAPTAIN.ASSISTANTS.SCENARIOS.EMPTY_MESSAGE') }} + + + + + {{ t('CAPTAIN.ASSISTANTS.SCENARIOS.SEARCH_EMPTY_MESSAGE') }} + + + + handleRuleHover(isHovered, scenario.id)" + /> + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Settings.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Settings.vue index f73b82094..d1324791c 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/settings/Settings.vue @@ -41,7 +41,7 @@ const controlItems = computed(() => { description: t( 'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.SCENARIOS.DESCRIPTION' ), - // routeName: 'captain_assistants_scenarios_index', + routeName: 'captain_assistants_scenarios_index', }, { name: t( diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js index b2257076a..52fda537b 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js +++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js @@ -7,6 +7,7 @@ import AssistantEdit from './assistants/Edit.vue'; import AssistantInboxesIndex from './assistants/inboxes/Index.vue'; import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue'; import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue'; +import AssistantScenariosIndex from './assistants/scenarios/Index.vue'; import DocumentsIndex from './documents/Index.vue'; import ResponsesIndex from './responses/Index.vue'; @@ -67,6 +68,21 @@ export const routes = [ ], }, }, + { + path: frontendURL( + 'accounts/:accountId/captain/assistants/:assistantId/scenarios' + ), + component: AssistantScenariosIndex, + name: 'captain_assistants_scenarios_index', + meta: { + permissions: ['administrator', 'agent'], + featureFlag: FEATURE_FLAGS.CAPTAIN, + installationTypes: [ + INSTALLATION_TYPES.CLOUD, + INSTALLATION_TYPES.ENTERPRISE, + ], + }, + }, { path: frontendURL( 'accounts/:accountId/captain/assistants/:assistantId/guidelines' diff --git a/app/javascript/dashboard/store/captain/scenarios.js b/app/javascript/dashboard/store/captain/scenarios.js new file mode 100644 index 000000000..d66992d85 --- /dev/null +++ b/app/javascript/dashboard/store/captain/scenarios.js @@ -0,0 +1,38 @@ +import CaptainScenarios from 'dashboard/api/captain/scenarios'; +import { createStore } from './storeFactory'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; + +export default createStore({ + name: 'CaptainScenario', + API: CaptainScenarios, + actions: mutations => ({ + update: async ({ commit }, { id, assistantId, ...updateObj }) => { + commit(mutations.SET_UI_FLAG, { updatingItem: true }); + try { + const response = await CaptainScenarios.update( + { id, assistantId }, + updateObj + ); + commit(mutations.EDIT, response.data); + commit(mutations.SET_UI_FLAG, { updatingItem: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { updatingItem: false }); + return throwErrorMessage(error); + } + }, + + delete: async ({ commit }, { id, assistantId }) => { + commit(mutations.SET_UI_FLAG, { deletingItem: true }); + try { + await CaptainScenarios.delete({ id, assistantId }); + commit(mutations.DELETE, id); + commit(mutations.SET_UI_FLAG, { deletingItem: false }); + return id; + } catch (error) { + commit(mutations.SET_UI_FLAG, { deletingItem: false }); + return throwErrorMessage(error); + } + }, + }), +}); diff --git a/app/javascript/dashboard/store/captain/tools.js b/app/javascript/dashboard/store/captain/tools.js new file mode 100644 index 000000000..9a9bcc330 --- /dev/null +++ b/app/javascript/dashboard/store/captain/tools.js @@ -0,0 +1,24 @@ +import { createStore } from './storeFactory'; +import CaptainToolsAPI from '../../api/captain/tools'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; + +const toolsStore = createStore({ + name: 'captainTool', + API: CaptainToolsAPI, + actions: mutations => ({ + getTools: async ({ commit }) => { + commit(mutations.SET_UI_FLAG, { fetchingList: true }); + try { + const response = await CaptainToolsAPI.get(); + commit(mutations.SET, response.data); + commit(mutations.SET_UI_FLAG, { fetchingList: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { fetchingList: false }); + return throwErrorMessage(error); + } + }, + }), +}); + +export default toolsStore; diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 960285ebf..5a020dda6 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -53,6 +53,8 @@ import captainInboxes from './captain/inboxes'; import captainBulkActions from './captain/bulkActions'; import copilotThreads from './captain/copilotThreads'; import copilotMessages from './captain/copilotMessages'; +import captainScenarios from './captain/scenarios'; +import captainTools from './captain/tools'; const plugins = []; @@ -111,6 +113,8 @@ export default createStore({ captainBulkActions, copilotThreads, copilotMessages, + captainScenarios, + captainTools, }, plugins, }); diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder index dc5860fb9..0b137d822 100644 --- a/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder +++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder @@ -1,5 +1,10 @@ -json.data do +json.payload do json.array! @scenarios do |scenario| json.partial! 'api/v1/models/captain/scenario', scenario: scenario end end + +json.meta do + json.total_count @scenarios.count + json.page 1 +end diff --git a/package.json b/package.json index 668ea8b57..e8c120074 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "dependencies": { "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.1.6-next", + "@chatwoot/prosemirror-schema": "1.2.1", "@chatwoot/utils": "^0.0.48", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16e830a87..0f7b398b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.1.6-next - version: 1.1.6-next + specifier: 1.2.1 + version: 1.2.1 '@chatwoot/utils': specifier: ^0.0.48 version: 0.0.48 @@ -403,8 +403,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.1.6-next': - resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==} + '@chatwoot/prosemirror-schema@1.2.1': + resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==} '@chatwoot/utils@0.0.48': resolution: {integrity: sha512-67M2lvpBp0Ciczv1uRzabOXSCGiEeJE3wYVoPAxkqI35CJSkotu4tSX2TFOwagUQoRyU6F8YV3xXGfCpDN9WAA==} @@ -5237,7 +5237,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.1.6-next': + '@chatwoot/prosemirror-schema@1.2.1': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.6.0 diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb index ed223622b..3e68c9e5e 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb @@ -26,7 +26,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do as: :json expect(response).to have_http_status(:success) - expect(json_response[:data].length).to eq(3) + expect(json_response[:payload].length).to eq(3) end end @@ -38,7 +38,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do as: :json expect(response).to have_http_status(:success) - expect(json_response[:data].length).to eq(5) + expect(json_response[:payload].length).to eq(5) end it 'returns only enabled scenarios' do @@ -49,8 +49,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do as: :json expect(response).to have_http_status(:success) - expect(json_response[:data].length).to eq(1) - expect(json_response[:data].first[:enabled]).to be(true) + expect(json_response[:payload].length).to eq(1) + expect(json_response[:payload].first[:enabled]).to be(true) end end end