Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abfd335f57 | ||
|
|
926a9d8a69 | ||
|
|
9caceea858 |
+1
-1
@@ -570,7 +570,7 @@ GEM
|
||||
minitest (5.25.5)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
msgpack (1.8.3)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.9.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
|
||||
-23
@@ -29,7 +29,6 @@ const initialState = {
|
||||
handoffMessage: '',
|
||||
resolutionMessage: '',
|
||||
instructions: '',
|
||||
temperature: 1,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
@@ -57,7 +56,6 @@ const updateStateFromAssistant = assistant => {
|
||||
state.handoffMessage = config.handoff_message;
|
||||
state.resolutionMessage = config.resolution_message;
|
||||
state.instructions = config.instructions;
|
||||
state.temperature = config.temperature || 1;
|
||||
};
|
||||
|
||||
const handleSystemMessagesUpdate = async () => {
|
||||
@@ -80,7 +78,6 @@ const handleSystemMessagesUpdate = async () => {
|
||||
...props.assistant.config,
|
||||
handoff_message: state.handoffMessage,
|
||||
resolution_message: state.resolutionMessage,
|
||||
temperature: state.temperature || 1,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,26 +128,6 @@ watch(
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
v-model="state.temperature"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11 italic">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { validateSingleFilter } from 'dashboard/helper/validations.js';
|
||||
const { filterTypes } = defineProps({
|
||||
showQueryOperator: { type: Boolean, default: false },
|
||||
filterTypes: { type: Array, required: true },
|
||||
searchableAttributes: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
@@ -199,6 +200,7 @@ defineExpose({ validate, resetValidation });
|
||||
v-model="attributeKey"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
:searchable="searchableAttributes"
|
||||
@update:model-value="resetModelOnAttributeKeyChange"
|
||||
/>
|
||||
<FilterSelect
|
||||
|
||||
@@ -23,7 +23,7 @@ const emit = defineEmits([
|
||||
'close',
|
||||
'clearFilters',
|
||||
]);
|
||||
const { filterTypes } = useContactFilterContext();
|
||||
const { attributeFilterTypes } = useContactFilterContext();
|
||||
|
||||
const filters = defineModel({
|
||||
type: Array,
|
||||
@@ -127,8 +127,9 @@ const outsideClickHandler = [
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:filter-types="attributeFilterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
@@ -140,7 +141,8 @@ const outsideClickHandler = [
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
:filter-types="attributeFilterTypes"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['applyFilter', 'updateFolder', 'close']);
|
||||
const { filterTypes } = useConversationFilterContext();
|
||||
const { attributeFilterTypes } = useConversationFilterContext();
|
||||
|
||||
const filters = defineModel({
|
||||
type: Array,
|
||||
@@ -128,8 +128,9 @@ const outsideClickHandler = [
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:filter-types="attributeFilterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
@@ -141,7 +142,8 @@ const outsideClickHandler = [
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
:filter-types="attributeFilterTypes"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildAttributesFilterTypes,
|
||||
CONTACT_ATTRIBUTES,
|
||||
} from './helper/filterHelper.js';
|
||||
import { groupFilterTypes } from './helper/filterAttributeIcons.js';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
|
||||
/**
|
||||
@@ -212,5 +213,10 @@ export function useContactFilterContext() {
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
return { filterTypes };
|
||||
// Same attributes, but grouped into sections with leading icons for the attribute picker.
|
||||
const attributeFilterTypes = computed(() =>
|
||||
groupFilterTypes(filterTypes.value, t)
|
||||
);
|
||||
|
||||
return { filterTypes, attributeFilterTypes };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Shared helpers to make the attribute picker in FilterSelect richer: a leading icon per
|
||||
* attribute and grouped, non-clickable section headers. Used by the conversation, contact and
|
||||
* automation filter builders so they stay visually consistent.
|
||||
*/
|
||||
|
||||
// Icon per known standard / additional attribute key (conversation + contact filters).
|
||||
const ATTRIBUTE_ICONS = {
|
||||
// Contact attributes
|
||||
name: 'i-lucide-user',
|
||||
email: 'i-lucide-mail',
|
||||
phone_number: 'i-lucide-phone',
|
||||
identifier: 'i-lucide-fingerprint',
|
||||
country_code: 'i-lucide-flag',
|
||||
city: 'i-lucide-map-pin',
|
||||
company_name: 'i-lucide-building-2',
|
||||
blocked: 'i-lucide-ban',
|
||||
// Conversation attributes
|
||||
status: 'i-lucide-circle-dot',
|
||||
priority: 'i-lucide-signal-high',
|
||||
assignee_id: 'i-lucide-user-round',
|
||||
inbox_id: 'i-lucide-inbox',
|
||||
team_id: 'i-lucide-users-round',
|
||||
contact_id: 'i-lucide-contact',
|
||||
display_id: 'i-lucide-hash',
|
||||
campaign_id: 'i-lucide-megaphone',
|
||||
browser_language: 'i-lucide-globe',
|
||||
conversation_language: 'i-lucide-languages',
|
||||
referer: 'i-lucide-link',
|
||||
// Shared
|
||||
labels: 'i-lucide-tags',
|
||||
created_at: 'i-lucide-calendar',
|
||||
last_activity_at: 'i-lucide-activity',
|
||||
};
|
||||
|
||||
// Icon per custom-attribute display type.
|
||||
const CUSTOM_TYPE_ICONS = {
|
||||
text: 'i-lucide-type',
|
||||
number: 'i-lucide-hash',
|
||||
currency: 'i-lucide-banknote',
|
||||
percent: 'i-lucide-percent',
|
||||
link: 'i-lucide-link',
|
||||
date: 'i-lucide-calendar',
|
||||
list: 'i-lucide-list',
|
||||
checkbox: 'i-lucide-square-check',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON = 'i-lucide-tag';
|
||||
|
||||
/**
|
||||
* Resolve the leading icon for a single filter type.
|
||||
* @param {Object} type - A FilterType entry.
|
||||
* @returns {string} The icon class to render.
|
||||
*/
|
||||
export const getAttributeIcon = type => {
|
||||
if (type.attributeModel === 'customAttributes') {
|
||||
return CUSTOM_TYPE_ICONS[type.attributeDisplayType] || DEFAULT_ICON;
|
||||
}
|
||||
return ATTRIBUTE_ICONS[type.attributeKey] || DEFAULT_ICON;
|
||||
};
|
||||
|
||||
// Order the groups appear in, keyed by attributeModel, with their i18n label keys.
|
||||
const GROUPS = [
|
||||
{ model: 'standard', label: 'FILTER.GROUPS.STANDARD_FILTERS' },
|
||||
{ model: 'additional', label: 'FILTER.GROUPS.ADDITIONAL_FILTERS' },
|
||||
{ model: 'customAttributes', label: 'FILTER.GROUPS.CUSTOM_ATTRIBUTES' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Attach a leading icon to each filter type and split them into grouped sections separated by
|
||||
* disabled header entries (which FilterSelect renders as non-clickable section titles).
|
||||
* @param {Object[]} filterTypes - Flat list of FilterType entries.
|
||||
* @param {Function} t - vue-i18n translate function.
|
||||
* @returns {Object[]} Grouped list with header + icon-enriched entries.
|
||||
*/
|
||||
export const groupFilterTypes = (filterTypes, t) => {
|
||||
const withIcon = type => ({
|
||||
...type,
|
||||
icon: type.icon || getAttributeIcon(type),
|
||||
});
|
||||
const knownModels = GROUPS.map(group => group.model);
|
||||
|
||||
const grouped = GROUPS.flatMap(({ model, label }) => {
|
||||
const group = filterTypes.filter(
|
||||
type => (type.attributeModel || 'standard') === model
|
||||
);
|
||||
if (!group.length) return [];
|
||||
return [
|
||||
{ value: `__group_${model}`, label: t(label), disabled: true },
|
||||
...group.map(withIcon),
|
||||
];
|
||||
});
|
||||
|
||||
// Append any attribute with an unexpected model rather than dropping it silently.
|
||||
const ungrouped = filterTypes.filter(
|
||||
type => !knownModels.includes(type.attributeModel || 'standard')
|
||||
);
|
||||
|
||||
return [...grouped, ...ungrouped.map(withIcon)];
|
||||
};
|
||||
@@ -78,6 +78,7 @@ export const buildAttributesFilterTypes = (
|
||||
attributeName: attr.attributeDisplayName,
|
||||
label: attr.attributeDisplayName,
|
||||
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
|
||||
attributeDisplayType: attr.attributeDisplayType,
|
||||
filterOperators: getOperatorTypes(attr.attributeDisplayType),
|
||||
options:
|
||||
attr.attributeDisplayType === 'list'
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('filterHelper', () => {
|
||||
attributeName: 'Test Name',
|
||||
label: 'Test Name',
|
||||
inputType: 'plainText',
|
||||
attributeDisplayType: 'text',
|
||||
filterOperators: ['contains', 'not_contains'],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
@@ -111,6 +112,7 @@ describe('filterHelper', () => {
|
||||
attributeName: 'List Name',
|
||||
label: 'List Name',
|
||||
inputType: 'searchSelect',
|
||||
attributeDisplayType: 'list',
|
||||
filterOperators: ['is', 'is_not'],
|
||||
options: [
|
||||
{ id: 'option1', name: 'option1' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useElementBounding, useWindowSize } from '@vueuse/core';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
@@ -7,6 +8,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
// [{label, icon, value}]
|
||||
const props = defineProps({
|
||||
@@ -30,6 +32,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selected = defineModel({
|
||||
@@ -37,9 +43,21 @@ const selected = defineModel({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const triggerRef = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!props.searchable || !query) return props.options;
|
||||
return props.options.filter(
|
||||
option =>
|
||||
!option.disabled && (option.label || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const { top } = useElementBounding(triggerRef);
|
||||
const { height } = useWindowSize();
|
||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||
@@ -65,6 +83,12 @@ const dropdownPosition = computed(() => {
|
||||
|
||||
const updateSelected = newValue => {
|
||||
selected.value = newValue;
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
const handleTriggerClick = toggle => {
|
||||
searchQuery.value = '';
|
||||
toggle();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -81,7 +105,7 @@ const updateSelected = newValue => {
|
||||
:icon="iconToRender"
|
||||
:trailing-icon="selectedOption.icon ? false : true"
|
||||
:label="label || (hideLabel ? null : selectedOption.label)"
|
||||
@click="toggle"
|
||||
@click="handleTriggerClick(toggle)"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
@@ -91,8 +115,17 @@ const updateSelected = newValue => {
|
||||
:class="dropdownPosition"
|
||||
strong
|
||||
>
|
||||
<div v-if="searchable" class="relative">
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
v-focus
|
||||
class="w-full p-1.5 pl-8 rounded-lg text-n-slate-11 bg-n-alpha-1"
|
||||
:placeholder="t('FILTER.SEARCH_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection class="[&>ul]:max-h-72">
|
||||
<template v-for="option in options" :key="option.value">
|
||||
<template v-for="option in filteredOptions" :key="option.value">
|
||||
<li
|
||||
v-if="option.disabled"
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||
@@ -106,6 +139,12 @@ const updateSelected = newValue => {
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
</template>
|
||||
<li
|
||||
v-if="searchable && !filteredOptions.length"
|
||||
class="px-2 py-1.5 text-sm text-n-slate-10 select-none"
|
||||
>
|
||||
{{ t('FILTER.NO_RESULTS') }}
|
||||
</li>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
} from './helper/filterHelper';
|
||||
import { groupFilterTypes } from './helper/filterAttributeIcons';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
/**
|
||||
@@ -287,5 +288,10 @@ export function useConversationFilterContext() {
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
return { filterTypes };
|
||||
// Same attributes, but grouped into sections with leading icons for the attribute picker.
|
||||
const attributeFilterTypes = computed(() =>
|
||||
groupFilterTypes(filterTypes.value, t)
|
||||
);
|
||||
|
||||
return { filterTypes, attributeFilterTypes };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"OR": "OR"
|
||||
},
|
||||
"INPUT_PLACEHOLDER": "Enter value",
|
||||
"SEARCH_PLACEHOLDER": "Search…",
|
||||
"NO_RESULTS": "No matches",
|
||||
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||
"CONTACT_FALLBACK": "Contact #{id}",
|
||||
"OPERATOR_LABELS": {
|
||||
|
||||
@@ -494,10 +494,6 @@
|
||||
"PLACEHOLDER": "Enter assistant name",
|
||||
"ERROR": "The name is required"
|
||||
},
|
||||
"TEMPERATURE": {
|
||||
"LABEL": "Response Temperature",
|
||||
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Enter assistant description",
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ import { ref, computed, h, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useOperators } from 'dashboard/components-next/filter/operators';
|
||||
import { getAttributeIcon } from 'dashboard/components-next/filter/helper/filterAttributeIcons';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -137,6 +138,7 @@ const filterTypes = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const attributeModel = attr.customAttributeType || 'standard';
|
||||
return {
|
||||
attributeKey: attr.key,
|
||||
value: attr.key,
|
||||
@@ -146,7 +148,8 @@ const filterTypes = computed(() => {
|
||||
options,
|
||||
filterOperators,
|
||||
dataType: 'text',
|
||||
attributeModel: attr.customAttributeType || 'standard',
|
||||
attributeModel,
|
||||
icon: getAttributeIcon({ attributeKey: attr.key, attributeModel }),
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -315,6 +318,7 @@ defineExpose({ open, close });
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
@@ -328,6 +332,7 @@ defineExpose({ open, close });
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
searchable-attributes
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -96,7 +96,7 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def temperature
|
||||
@assistant&.config&.[]('temperature').to_f || 1
|
||||
@assistant&.config&.[]('temperature').presence&.to_f || 0.5
|
||||
end
|
||||
|
||||
def resolved_account_id
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
module Concerns::Agentable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
DEFAULT_TEMPERATURE = 0.5
|
||||
|
||||
def agent
|
||||
Agents::Agent.new(
|
||||
name: agent_name,
|
||||
instructions: ->(context) { agent_instructions(context) },
|
||||
tools: agent_tools,
|
||||
model: agent_model,
|
||||
temperature: temperature.to_f || 0.7,
|
||||
temperature: temperature.presence&.to_f || DEFAULT_TEMPERATURE,
|
||||
response_schema: agent_response_schema
|
||||
)
|
||||
end
|
||||
|
||||
@@ -56,11 +56,11 @@ RSpec.describe Concerns::Agentable do
|
||||
dummy_instance.agent
|
||||
end
|
||||
|
||||
it 'converts nil temperature to 0.0' do
|
||||
it 'uses default temperature when temperature is nil' do
|
||||
dummy_instance.temperature = nil
|
||||
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
hash_including(temperature: 0.0)
|
||||
hash_including(temperature: 0.5)
|
||||
)
|
||||
|
||||
dummy_instance.agent
|
||||
|
||||
@@ -39,6 +39,24 @@ RSpec.describe Captain::Llm::AssistantChatService do
|
||||
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
|
||||
end
|
||||
|
||||
it 'uses default temperature when assistant config does not include temperature' do
|
||||
expect(mock_chat).to receive(:with_temperature).with(0.5).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
|
||||
end
|
||||
|
||||
it 'preserves explicit assistant config temperature' do
|
||||
assistant.update!(config: assistant.config.merge('temperature' => 1.0))
|
||||
|
||||
expect(mock_chat).to receive(:with_temperature).with(1.0).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
|
||||
end
|
||||
|
||||
it 'passes channel_type to the agent session instrumentation' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user