Compare commits

...
Author SHA1 Message Date
PranavandClaude Opus 4.8 abfd335f57 feat(filter): richer attribute picker with icons, groups and search
Add a leading icon and grouped, searchable sections to the attribute
picker used by conversation filters, contact filters/segments and
automation rule conditions. FilterSelect gains a `searchable` option
(type-ahead + empty state) and a shared helper supplies per-attribute
icons and section headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:25:01 -07:00
Aakash BakhleandGitHub 926a9d8a69 fix(captain): default temperature to 0.5 and remove UI control (#14879)
# Pull Request Template

## Description

- Default temperature to 0.5 and remove UI control
- No migrations needed for existing accounts, their current settings are
preserved

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and spec

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-30 15:14:03 +05:30
Aakash BakhleandGitHub 9caceea858 fix(deps): update msgpack for CVE-2026-54522 (#14898)
# Pull Request Template

## Description

This updates the locked `msgpack` gem from `1.8.0` to `1.8.3` so the
bundle-audit check no longer flags CVE-2026-54522. The upgrade stays
within the existing transitive dependency constraints used by `bootsnap`
and `datadog`.

Fixes:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114757/workflows/f8c7b37f-27d5-45d4-9f1b-1d1782ebc4e3/jobs/162250

## Type of change

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

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec bundle audit update && bundle
exec bundle audit check -v`
- `eval "$(rbenv init -)" && bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `eval "$(rbenv init -)" && RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle
exec rubocop --no-server Gemfile`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-30 14:56:34 +05:30
18 changed files with 203 additions and 43 deletions
+1 -1
View File
@@ -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)
@@ -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",
@@ -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
+3 -1
View File
@@ -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)