407 lines
12 KiB
Vue
Executable File
407 lines
12 KiB
Vue
Executable File
<script setup>
|
|
import { computed, h, reactive, ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useToggle, useElementSize } from '@vueuse/core';
|
|
import { useVuelidate } from '@vuelidate/core';
|
|
import { required, minLength } from '@vuelidate/validators';
|
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
|
import { useMapGetter } from 'dashboard/composables/store';
|
|
import Input from 'dashboard/components-next/input/Input.vue';
|
|
import Button from 'dashboard/components-next/button/Button.vue';
|
|
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
|
import ScenariosAPI from 'dashboard/api/captain/scenarios';
|
|
import { useRoute } from 'vue-router';
|
|
import { useAlert } from 'dashboard/composables';
|
|
|
|
const props = defineProps({
|
|
id: {
|
|
type: [Number, String],
|
|
required: true,
|
|
},
|
|
title: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
description: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
instruction: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
tools: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
selectable: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
isSelected: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
triggerKeywords: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
enabled: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['select', 'hover', 'delete', 'update', 'duplicate']);
|
|
|
|
const { t } = useI18n();
|
|
const { formatMessage } = useMessageFormatter();
|
|
|
|
const modelValue = computed({
|
|
get: () => props.isSelected,
|
|
set: () => emit('select', props.id),
|
|
});
|
|
|
|
const state = reactive({
|
|
id: '',
|
|
title: '',
|
|
description: '',
|
|
instruction: '',
|
|
trigger_keywords: '',
|
|
tools: [],
|
|
enabled: true,
|
|
});
|
|
|
|
const instructionContentRef = ref();
|
|
|
|
const [isEditing, toggleEditing] = useToggle();
|
|
const [isInstructionExpanded, toggleInstructionExpanded] = useToggle();
|
|
const isSuggesting = ref(false);
|
|
const route = useRoute();
|
|
|
|
const { height: contentHeight } = useElementSize(instructionContentRef);
|
|
const needsOverlay = computed(() => contentHeight.value > 160);
|
|
|
|
const allTools = useMapGetter('captainTools/getRecords');
|
|
|
|
const toolOptions = computed(() => {
|
|
const options = allTools.value.map(tool => ({
|
|
label: tool.title,
|
|
value: tool.id,
|
|
}));
|
|
return options;
|
|
});
|
|
|
|
const startEdit = () => {
|
|
Object.assign(state, {
|
|
id: props.id,
|
|
title: props.title,
|
|
description: props.description,
|
|
instruction: props.instruction,
|
|
trigger_keywords: props.triggerKeywords,
|
|
tools: props.tools || [],
|
|
enabled: props.enabled,
|
|
});
|
|
toggleEditing(true);
|
|
};
|
|
|
|
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 onClickUpdate = () => {
|
|
v$.value.$touch();
|
|
if (v$.value.$invalid) return;
|
|
emit('update', { ...state });
|
|
toggleEditing(false);
|
|
};
|
|
|
|
const instructionError = computed(() =>
|
|
v$.value.instruction.$error
|
|
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
|
|
: ''
|
|
);
|
|
|
|
const onDeleteOrArchive = () => {
|
|
if (props.selectable) {
|
|
emit('update', { id: props.id, enabled: false });
|
|
} else {
|
|
emit('delete', props.id);
|
|
}
|
|
};
|
|
|
|
const LINK_INSTRUCTION_CLASS =
|
|
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
|
|
|
|
const renderInstruction = instruction => () =>
|
|
h('p', {
|
|
class: `text-sm text-n-slate-12 py-4 mb-0 prose prose-sm min-w-0 break-words max-w-none ${LINK_INSTRUCTION_CLASS}`,
|
|
innerHTML: instruction,
|
|
});
|
|
|
|
const emitDuplicate = () => {
|
|
emit('duplicate', {
|
|
id: props.id,
|
|
title: props.title,
|
|
description: props.description,
|
|
instruction: props.instruction,
|
|
tools: props.tools || [],
|
|
});
|
|
};
|
|
|
|
const onSuggestTriggers = async () => {
|
|
if (!state.instruction && !state.title) {
|
|
// using generic error message for now, can be extracted to i18n
|
|
useAlert(
|
|
t(
|
|
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.SUGGEST_ERROR'
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
isSuggesting.value = true;
|
|
try {
|
|
const assistantId = route.params.assistantId;
|
|
const response = await ScenariosAPI.suggestTriggers({
|
|
assistantId,
|
|
title: state.title,
|
|
description: state.description,
|
|
instruction: state.instruction,
|
|
});
|
|
|
|
if (response.data.keywords) {
|
|
state.trigger_keywords = response.data.keywords;
|
|
useAlert(
|
|
t(
|
|
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.SUGGEST_SUCCESS'
|
|
)
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error?.response?.data?.error || 'Failed to suggest keywords';
|
|
useAlert(errorMessage);
|
|
} finally {
|
|
isSuggesting.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
|
<CardLayout
|
|
selectable
|
|
class="relative [&>div]:!py-4"
|
|
:class="{
|
|
'[&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4': !isEditing,
|
|
'[&>div]:ltr:!pr-10 [&>div]:rtl:!pl-10': isEditing,
|
|
'opacity-60 saturate-50': !enabled && !isEditing,
|
|
}"
|
|
layout="row"
|
|
@mouseenter="emit('hover', true)"
|
|
@mouseleave="emit('hover', false)"
|
|
>
|
|
<div
|
|
v-show="selectable && !isEditing"
|
|
class="absolute top-[1.125rem] ltr:left-3 rtl:right-3"
|
|
>
|
|
<Checkbox v-model="modelValue" />
|
|
</div>
|
|
|
|
<div v-if="!isEditing" class="flex flex-col w-full">
|
|
<div class="flex items-start justify-between w-full gap-2">
|
|
<div class="flex flex-col items-start">
|
|
<span class="text-sm text-n-slate-12 font-medium">
|
|
{{ title }}
|
|
<span
|
|
v-if="!enabled"
|
|
class="text-n-slate-11 text-xs font-normal ml-2"
|
|
>
|
|
{{
|
|
`(${t('CAPTAIN.ASSISTANTS.SCENARIOS.DISABLED') || 'Desativado'})`
|
|
}}
|
|
</span>
|
|
</span>
|
|
<span class="text-sm text-n-slate-11 mt-2">
|
|
{{ description }}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<!-- <Button label="Test" slate xs ghost class="!text-sm" />
|
|
<span class="w-px h-4 bg-n-weak" /> -->
|
|
<Button icon="i-lucide-copy" slate xs ghost @click="emitDuplicate" />
|
|
<span class="w-px h-4 bg-n-weak" />
|
|
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
|
|
<span class="w-px h-4 bg-n-weak" />
|
|
<Button
|
|
:icon="selectable ? 'i-lucide-archive' : 'i-lucide-trash'"
|
|
slate
|
|
xs
|
|
ghost
|
|
:title="
|
|
selectable
|
|
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ARCHIVE')
|
|
: t('CAPTAIN.ASSISTANTS.SCENARIOS.DELETE')
|
|
"
|
|
@click="onDeleteOrArchive"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="relative overflow-hidden transition-all duration-300 ease-in-out group/expandable"
|
|
:class="{ 'cursor-pointer': needsOverlay }"
|
|
:style="{
|
|
maxHeight: isInstructionExpanded ? `${contentHeight}px` : '10rem',
|
|
}"
|
|
@click="needsOverlay ? toggleInstructionExpanded() : null"
|
|
>
|
|
<div ref="instructionContentRef">
|
|
<component
|
|
:is="renderInstruction(formatMessage(instruction, false))"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="absolute bottom-0 w-full flex items-end justify-center text-xs text-n-slate-11 bg-gradient-to-t h-40 from-n-solid-2 via-n-solid-2 via-10% to-transparent transition-all duration-500 ease-in-out px-2 py-1 rounded pointer-events-none"
|
|
:class="{
|
|
'visible opacity-100': !isInstructionExpanded,
|
|
'invisible opacity-0': isInstructionExpanded || !needsOverlay,
|
|
}"
|
|
>
|
|
<Icon
|
|
icon="i-lucide-chevron-down"
|
|
class="text-n-slate-7 mb-4 size-4 group-hover/expandable:text-n-slate-11 transition-colors duration-200"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span
|
|
v-if="tools?.length"
|
|
class="text-sm text-n-slate-11 font-medium mb-1"
|
|
>
|
|
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
|
{{ tools?.map(tool => `@${tool}`).join(', ') }}
|
|
</span>
|
|
</div>
|
|
<div v-else class="flex flex-col gap-4 w-full">
|
|
<Input
|
|
v-model="state.title"
|
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
|
|
:placeholder="
|
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
|
|
"
|
|
:message="titleError"
|
|
:message-type="titleError ? 'error' : 'info'"
|
|
/>
|
|
|
|
<TextArea
|
|
v-model="state.description"
|
|
:label="
|
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
|
"
|
|
:placeholder="
|
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER')
|
|
"
|
|
:message="descriptionError"
|
|
:message-type="descriptionError ? 'error' : 'info'"
|
|
show-character-count
|
|
/>
|
|
<Editor
|
|
v-model="state.instruction"
|
|
:label="
|
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
|
|
"
|
|
:placeholder="
|
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER')
|
|
"
|
|
:message="instructionError"
|
|
:message-type="instructionError ? 'error' : 'info'"
|
|
:show-character-count="false"
|
|
enable-captain-tools
|
|
/>
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex items-center justify-between">
|
|
<label class="text-xs font-medium text-n-slate-11">
|
|
{{
|
|
t(
|
|
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.LABEL'
|
|
)
|
|
}}
|
|
</label>
|
|
<Button
|
|
:label="
|
|
t(
|
|
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.SUGGEST_BUTTON'
|
|
)
|
|
"
|
|
icon="i-lucide-sparkles"
|
|
xs
|
|
ghost
|
|
slate
|
|
:is-loading="isSuggesting"
|
|
@click="onSuggestTriggers"
|
|
/>
|
|
</div>
|
|
<TextArea
|
|
v-model="state.trigger_keywords"
|
|
:placeholder="
|
|
t(
|
|
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.PLACEHOLDER'
|
|
)
|
|
"
|
|
min-height="80px"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label class="text-xs font-medium text-n-slate-11">
|
|
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TOOLS.LABEL') }}
|
|
</label>
|
|
<TagMultiSelectComboBox
|
|
v-model="state.tools"
|
|
:options="toolOptions"
|
|
:placeholder="
|
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TOOLS.PLACEHOLDER')
|
|
"
|
|
/>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<Button
|
|
faded
|
|
slate
|
|
sm
|
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.CANCEL')"
|
|
@click="toggleEditing(false)"
|
|
/>
|
|
<Button
|
|
sm
|
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.UPDATE')"
|
|
@click="onClickUpdate"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardLayout>
|
|
</template>
|