From e683cd36735a5aab49b54f74bb7a0bb366bbbf34 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:43:38 +0800 Subject: [PATCH] Add reasoning effort slider (#2097) * Add reasoning effort slider * Link reasoning slider change fragment * Fix ChatInput Naive UI test mocks --- .../2026-07-16-reasoning-effort-slider.md | 11 + .../src/components/hermes/chat/ChatInput.vue | 245 ++++++++++++++++-- tests/client/chat-input-draft.test.ts | 46 ++-- tests/client/voice-dialogue-controls.test.ts | 3 +- 4 files changed, 255 insertions(+), 50 deletions(-) create mode 100644 docs/chat-chain-changes/2026-07-16-reasoning-effort-slider.md diff --git a/docs/chat-chain-changes/2026-07-16-reasoning-effort-slider.md b/docs/chat-chain-changes/2026-07-16-reasoning-effort-slider.md new file mode 100644 index 00000000..7b958bff --- /dev/null +++ b/docs/chat-chain-changes/2026-07-16-reasoning-effort-slider.md @@ -0,0 +1,11 @@ +--- +date: 2026-07-16 +pr: 2097 +feature: Reasoning effort slider +impact: Chat reasoning effort is selected from a discrete slider while preserving the existing per-session values, persistence, and run behavior. +--- + +The chat input keeps its brain toolbar button and opens a compact slider for +the existing default, none, minimal, low, medium, high, xhigh, and max levels. +MoA sessions continue to hide the control, and changing the slider still +affects only the active session's subsequent runs. diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index 45de2676..1616c746 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -7,7 +7,7 @@ import { useSettingsStore } from '@/stores/hermes/settings' import { fetchContextLength } from '@/api/hermes/sessions' import { setModelContext } from '@/api/hermes/model-context' import { fetchSkills, type SkillCategory, type SkillInfo } from '@/api/hermes/skills' -import { NButton, NTooltip, NModal, NInputNumber, NPopselect, NDropdown, useMessage, type DropdownOption } from 'naive-ui' +import { NButton, NTooltip, NModal, NInputNumber, NPopover, NSlider, NDropdown, useMessage, type DropdownOption } from 'naive-ui' import { computed, ref, nextTick, onMounted, onUnmounted, watch, h } from 'vue' import { useI18n } from 'vue-i18n' import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility' @@ -57,6 +57,24 @@ const reasoningEffortOptions = computed(() => [ const currentReasoningEffort = computed(() => chatStore.activeSession?.reasoningEffort || '' ) +const reasoningEffortSliderValue = computed(() => { + const index = reasoningEffortOptions.value.findIndex(option => option.value === currentReasoningEffort.value) + return index >= 0 ? index : 0 +}) +const reasoningEffortAccentColors = [ + '#94a3b8', + '#2ac8e9', + '#2bd9b4', + '#4ed786', + '#b9d93a', + '#f9c33c', + '#f77734', + '#ef4444', +] as const +const reasoningEffortAccentStyle = computed(() => ({ + '--reasoning-effort-accent-color': reasoningEffortAccentColors[reasoningEffortSliderValue.value] + || reasoningEffortAccentColors[0], +})) const isMoaSession = computed(() => chatStore.activeSession?.provider === 'moa') const reasoningEffortLabel = computed(() => { const v = currentReasoningEffort.value @@ -69,6 +87,14 @@ function onReasoningEffortChange(value: string | null | undefined) { if (!sid) return chatStore.setSessionReasoningEffort(sid, value || '') } +function reasoningEffortSliderLabel(value: number) { + return reasoningEffortOptions.value[Math.round(value)]?.label || reasoningEffortLabel.value +} +function onReasoningEffortSliderChange(value: number | [number, number]) { + const numericValue = Array.isArray(value) ? value[0] : value + const option = reasoningEffortOptions.value[Math.round(numericValue)] + if (option) onReasoningEffortChange(option.value) +} function handleModelButtonClick() { emit('modelClick') @@ -1110,35 +1136,57 @@ function isImage(type: string): boolean { {{ t('chat.attachFiles') }} - - - - {{ t('chat.reasoningEffort.tooltip') }}: {{ reasoningEffortLabel }} - - + + +
+
+ {{ t('chat.reasoningEffort.tooltip') }} + {{ reasoningEffortLabel }} +
+ + +
+ ({ NDropdown: { template: '
' }, NModal: { template: '
' }, NInputNumber: { template: '' }, - NPopselect: { - props: ['value', 'options'], + NPopover: { + template: '
', + }, + NSlider: { + props: ['value', 'min', 'max', 'step'], emits: ['update:value'], template: ` -
- - -
+ `, }, useMessage: () => ({ error: vi.fn(), success: vi.fn() }), @@ -178,9 +177,10 @@ describe('ChatInput draft persistence', () => { }) await nextTick() - expect(wrapper.find('.n-popselect-stub').exists()).toBe(true) - expect(wrapper.find('[data-value="high"]').exists()).toBe(true) - expect(wrapper.find('[data-value="max"]').exists()).toBe(true) + expect(wrapper.find('.n-popover-stub').exists()).toBe(true) + expect(wrapper.find('.n-slider-stub').exists()).toBe(true) + expect(wrapper.get('.n-slider-stub').attributes('min')).toBe('0') + expect(wrapper.get('.n-slider-stub').attributes('max')).toBe('7') }) it('hides the reasoning effort selector for MoA sessions', async () => { @@ -190,29 +190,33 @@ describe('ChatInput draft persistence', () => { }) await nextTick() - expect(wrapper.find('.n-popselect-stub').exists()).toBe(false) + expect(wrapper.find('.n-popover-stub').exists()).toBe(false) }) it('stores maximum reasoning effort for the active session', async () => { const wrapper = mountForSession('session-reasoning-max') const store = useChatStore() - await wrapper.get('[data-value="max"]').trigger('click') + await wrapper.get('.n-slider-stub').setValue('7') await nextTick() expect(store.sessions[0].reasoningEffort).toBe('max') expect(localStorage.getItem('hermes:reasoning_effort:session-reasoning-max')).toBe('max') + expect(wrapper.get('.reasoning-effort-button').attributes('style')).toContain('--reasoning-effort-accent-color: #ef4444') + expect(wrapper.get('.n-slider-stub').classes()).toContain('reasoning-effort-slider--max') }) it('stores the selected reasoning effort for the active session', async () => { const wrapper = mountForSession('session-reasoning') const store = useChatStore() - await wrapper.get('[data-value="high"]').trigger('click') + await wrapper.get('.n-slider-stub').setValue('5') await nextTick() expect(store.sessions[0].reasoningEffort).toBe('high') expect(localStorage.getItem('hermes:reasoning_effort:session-reasoning')).toBe('high') + expect(wrapper.get('.reasoning-effort-button').attributes('style')).toContain('--reasoning-effort-accent-color: #f9c33c') + expect(wrapper.get('.n-slider-stub').classes()).not.toContain('reasoning-effort-slider--max') }) it('opens the skill picker from /skill and inserts the selected skill command', async () => { diff --git a/tests/client/voice-dialogue-controls.test.ts b/tests/client/voice-dialogue-controls.test.ts index c52d0399..ecac8b6f 100644 --- a/tests/client/voice-dialogue-controls.test.ts +++ b/tests/client/voice-dialogue-controls.test.ts @@ -90,7 +90,8 @@ vi.mock('naive-ui', () => ({ NDropdown: { name: 'NDropdown', props: ['options'], emits: ['select'], template: '
' }, NModal: { template: '
' }, NInputNumber: { template: '' }, - NPopselect: { template: '
' }, + NPopover: { template: '
' }, + NSlider: { template: '
' }, useMessage: () => ({ error: vi.fn(), success: vi.fn() }), }))