Add reasoning effort slider (#2097)

* Add reasoning effort slider

* Link reasoning slider change fragment

* Fix ChatInput Naive UI test mocks
This commit is contained in:
ekko
2026-07-16 19:43:38 +08:00
committed by GitHub
parent 5be85483aa
commit e683cd3673
4 changed files with 255 additions and 50 deletions
@@ -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.
@@ -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<string>(() =>
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<string>(() => {
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') }}
</NTooltip>
<NPopselect
<NPopover
v-if="!isMoaSession"
:value="currentReasoningEffort"
:options="reasoningEffortOptions"
trigger="click"
@update:value="onReasoningEffortChange"
placement="top-start"
>
<NTooltip trigger="hover" :disabled="isMobileViewport">
<template #trigger>
<NButton
quaternary
size="tiny"
class="reasoning-effort-button"
:class="{ active: !!currentReasoningEffort }"
:aria-label="`${t('chat.reasoningEffort.tooltip')}: ${reasoningEffortLabel}`"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/>
</svg>
</template>
<span class="reasoning-effort-label">{{ reasoningEffortLabel }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</NButton>
</template>
{{ t('chat.reasoningEffort.tooltip') }}: {{ reasoningEffortLabel }}
</NTooltip>
</NPopselect>
<template #trigger>
<NTooltip trigger="hover" :disabled="isMobileViewport">
<template #trigger>
<NButton
quaternary
size="tiny"
class="reasoning-effort-button"
:class="{ active: !!currentReasoningEffort }"
:style="reasoningEffortAccentStyle"
:aria-label="`${t('chat.reasoningEffort.tooltip')}: ${reasoningEffortLabel}`"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/>
</svg>
</template>
<span class="reasoning-effort-label">{{ reasoningEffortLabel }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</NButton>
</template>
{{ t('chat.reasoningEffort.tooltip') }}: {{ reasoningEffortLabel }}
</NTooltip>
</template>
<div class="reasoning-effort-slider-popover" :style="reasoningEffortAccentStyle">
<div class="reasoning-effort-slider-heading">
<span>{{ t('chat.reasoningEffort.tooltip') }}</span>
<strong>{{ reasoningEffortLabel }}</strong>
</div>
<NSlider
class="reasoning-effort-slider"
:class="{ 'reasoning-effort-slider--max': currentReasoningEffort === 'max' }"
:value="reasoningEffortSliderValue"
:min="0"
:max="reasoningEffortOptions.length - 1"
:step="1"
:format-tooltip="reasoningEffortSliderLabel"
@update:value="onReasoningEffortSliderChange"
/>
<div class="reasoning-effort-slider-range" aria-hidden="true">
<span>{{ reasoningEffortOptions[0].label }}</span>
<span>{{ reasoningEffortOptions[reasoningEffortOptions.length - 1].label }}</span>
</div>
</div>
</NPopover>
<NDropdown
trigger="click"
@@ -1488,7 +1536,7 @@ function isImage(type: string): boolean {
padding: 0 4px 0 6px;
&.active {
color: #4caf50;
color: var(--reasoning-effort-accent-color);
}
:deep(.n-button__content) {
@@ -1506,6 +1554,139 @@ function isImage(type: string): boolean {
white-space: nowrap;
}
.reasoning-effort-slider-popover {
width: min(320px, calc(100vw - 64px));
padding: 4px 2px 2px;
}
.reasoning-effort-slider-heading,
.reasoning-effort-slider-range {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.reasoning-effort-slider-heading {
margin-bottom: 10px;
color: $text-secondary;
font-size: 12px;
strong {
color: var(--reasoning-effort-accent-color);
font-weight: 600;
}
}
.reasoning-effort-slider {
--n-handle-size: 24px !important;
--n-rail-height: 10px !important;
--reasoning-effort-gradient-width: min(314px, calc(100vw - 70px));
margin: 0 3px;
:deep(.n-slider-rail) {
background: rgba(255, 255, 255, 0.14);
}
:deep(.n-slider-rail__fill) {
background: linear-gradient(
90deg,
#38bdf8 0%,
#22d3ee 20%,
#34d399 40%,
#facc15 62%,
#fb923c 82%,
#ef4444 100%
);
background-position: left center;
background-repeat: no-repeat;
background-size: var(--reasoning-effort-gradient-width) 100%;
box-shadow: 0 0 8px rgba(56, 189, 248, 0.24);
}
:deep(.n-slider-handle) {
border: 2px solid rgba(255, 255, 255, 0.92);
background: #f8fafc;
box-shadow: 0 2px 8px rgba(24, 18, 44, 0.38);
}
}
.reasoning-effort-slider--max {
:deep(.n-slider-handle) {
position: relative;
overflow: hidden;
isolation: isolate;
border-radius: 50%;
border-color: rgba(255, 255, 255, 0.96);
background:
radial-gradient(circle at 24% 24%, #38bdf8 0 14%, transparent 34%),
radial-gradient(circle at 78% 22%, #facc15 0 15%, transparent 36%),
radial-gradient(circle at 78% 78%, #ef4444 0 16%, transparent 38%),
radial-gradient(circle at 22% 76%, #34d399 0 15%, transparent 36%),
conic-gradient(from 30deg, #22d3ee, #34d399, #facc15, #fb923c, #ef4444, #a855f7, #38bdf8, #22d3ee);
background-size: 150% 150%, 145% 145%, 155% 155%, 145% 145%, 180% 180%;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.38),
0 0 12px rgba(239, 68, 68, 0.46),
0 3px 9px rgba(24, 18, 44, 0.44);
animation: reasoning-effort-max-liquid 3.6s ease-in-out infinite;
}
:deep(.n-slider-handle::after) {
content: '';
position: absolute;
inset: 2px 5px 11px 5px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.5);
filter: blur(1px);
animation: reasoning-effort-max-highlight 2.8s ease-in-out infinite;
}
}
@keyframes reasoning-effort-max-liquid {
0%, 100% {
background-position: 0% 20%, 100% 0%, 100% 100%, 0% 100%, 50% 50%;
background-size: 150% 150%, 145% 145%, 155% 155%, 145% 145%, 180% 180%;
}
33% {
background-position: 65% 0%, 55% 70%, 30% 100%, 0% 35%, 100% 35%;
background-size: 175% 135%, 135% 175%, 165% 140%, 140% 165%, 210% 170%;
}
66% {
background-position: 100% 70%, 20% 100%, 0% 35%, 75% 0%, 0% 70%;
background-size: 135% 175%, 170% 140%, 140% 170%, 170% 135%, 170% 210%;
}
}
@keyframes reasoning-effort-max-highlight {
0%, 100% {
opacity: 0.72;
transform: translate(-1px, -1px) rotate(0deg);
}
50% {
opacity: 0.42;
transform: translate(3px, 2px) rotate(180deg);
}
}
@media (prefers-reduced-motion: reduce) {
.reasoning-effort-slider--max {
:deep(.n-slider-handle),
:deep(.n-slider-handle::after) {
animation: none;
}
}
}
.reasoning-effort-slider-range {
margin-top: 4px;
color: $text-muted;
font-size: 10px;
}
.toolbar-chevron {
flex: 0 0 12px;
color: $text-muted;
@@ -1622,6 +1803,14 @@ function isImage(type: string): boolean {
display: none;
}
.reasoning-effort-slider-popover {
width: min(220px, calc(100vw - 96px));
}
.reasoning-effort-slider {
--reasoning-effort-gradient-width: min(214px, calc(100vw - 102px));
}
.input-model-button {
min-width: 35px;
max-width: 35px;
+25 -21
View File
@@ -20,23 +20,22 @@ vi.mock('naive-ui', () => ({
NDropdown: { template: '<div><slot /></div>' },
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
NInputNumber: { template: '<input />' },
NPopselect: {
props: ['value', 'options'],
NPopover: {
template: '<div class="n-popover-stub"><slot name="trigger" /><slot /></div>',
},
NSlider: {
props: ['value', 'min', 'max', 'step'],
emits: ['update:value'],
template: `
<div class="n-popselect-stub">
<slot />
<button
v-for="option in options"
:key="option.value"
type="button"
class="n-popselect-option"
:data-value="option.value"
@click="$emit('update:value', option.value)"
>
{{ option.label }}
</button>
</div>
<input
class="n-slider-stub"
type="range"
:value="value"
:min="min"
:max="max"
:step="step"
@input="$emit('update:value', Number($event.target.value))"
/>
`,
},
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 () => {
+2 -1
View File
@@ -90,7 +90,8 @@ vi.mock('naive-ui', () => ({
NDropdown: { name: 'NDropdown', props: ['options'], emits: ['select'], template: '<div><slot /></div>' },
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
NInputNumber: { template: '<input />' },
NPopselect: { template: '<div><slot /></div>' },
NPopover: { template: '<div><slot name="trigger" /><slot /></div>' },
NSlider: { template: '<div></div>' },
useMessage: () => ({ error: vi.fn(), success: vi.fn() }),
}))