feat(ui): add reasoning effort and reasoning off controls to chat (#12700)

The chat could not set reasoning_effort or turn reasoning traces off.

Add an effort menu and a "No reasoning" toggle to the composer chips,
and the matching /effort and /reasoning slash actions. Both are stored
per chat. The effort menu defaults to the model default, which sends no
reasoning_effort, so existing chats behave as before.

Chosen effort goes out as the per-request reasoning_effort field. The
toggle sends "none", which the server maps to enable_thinking=false, and
also drops any reasoning the model still returns, so the thread shows
only the answer.

Assisted-by: Claude:claude-sonnet-5-5 eslint playwright

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-14 22:28:42 +02:00
1 parent 6d185cc2f8
commit fc6779d41e
8 files changed
+267 -11

No files matched your search

+2 -2
View File
@@ -72,7 +72,7 @@ test.describe('Chat composer', () => {
await expect(list).toBeVisible()
await expect(list.locator('.dk-cmd-label')).toHaveText(['Chat', 'View'])
const names = await list.locator('.dk-cmd-name').allTextContents()
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/canvas', '/find', '/settings', '/export', '/clear'])
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/reasoning', '/effort', '/canvas', '/find', '/settings', '/export', '/clear'])
await input(page).fill('/set')
await expect(list.locator('.dk-cmd-name')).toHaveText(['/settings'])
@@ -149,7 +149,7 @@ test.describe('Chat composer', () => {
await openChat(page, [chatOf('c1', 'Empty', 'qwen3-8b', [])])
await input(page).fill('/')
const names = await page.getByRole('listbox', { name: 'Actions' }).locator('.dk-cmd-name').allTextContents()
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/canvas', '/settings'])
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/reasoning', '/effort', '/canvas', '/settings'])
})
test('"/" from outside a text field starts a command', async ({ page }) => {
@@ -0,0 +1,73 @@
import { test, expect } from './coverage-fixtures.js'
import { chatOf, pair, mockChat, openChat, storedChats } from './chat-fixtures.js'
// The composer has two reasoning controls, stored per chat: an effort select
// and a "No reasoning" toggle. Both are also slash actions.
const sendWith = async (page, answer) => {
let body = null
await page.route('**/v1/chat/completions', (route) => {
body = route.request().postDataJSON()
route.fulfill({ status: 200, contentType: 'text/event-stream', body: answer })
})
await page.getByTestId('chat-input').fill('hello')
await page.getByTestId('chat-send').click()
await expect.poll(() => body).not.toBeNull()
return body
}
const stream = (...deltas) => deltas.map(d => `data: ${JSON.stringify({ choices: [{ delta: d }] })}\n\n`).join('')
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`
test.describe('Chat reasoning controls', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page)
})
test('sends no reasoning_effort until a level is picked, then sends it', async ({ page }) => {
await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))])
const chip = page.getByTestId('chat-effort-chip')
await expect(chip).toContainText('Model default')
let body = await sendWith(page, stream({ content: 'ok' }))
expect(body.reasoning_effort).toBeUndefined()
await chip.click()
await page.getByTestId('chat-effort-high').click()
await expect(chip).toContainText('High')
await expect.poll(async () => (await storedChats(page)).chats[0].reasoningEffort).toBe('high')
await page.unroute('**/v1/chat/completions')
body = await sendWith(page, stream({ content: 'ok' }))
expect(body.reasoning_effort).toBe('high')
})
test('the toggle asks for "none" and hides reasoning the model still sends', async ({ page }) => {
await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))])
const toggle = page.getByTestId('chat-reasoning-off')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByTestId('chat-effort-chip')).toBeDisabled()
const body = await sendWith(page, stream({ reasoning: 'secret plan' }, { content: 'The answer.' }))
expect(body.reasoning_effort).toBe('none')
await expect(page.getByText('The answer.')).toBeVisible()
await expect(page.getByText('secret plan')).toHaveCount(0)
const history = (await storedChats(page)).chats[0].history
expect(history.some(m => m.role === 'thinking')).toBe(false)
})
test('the slash actions toggle reasoning and open the effort select', async ({ page }) => {
await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))])
await page.getByTestId('chat-input').fill('/reasoning')
await page.getByTestId('chat-input').press('Enter')
await expect(page.getByTestId('chat-reasoning-off')).toHaveAttribute('aria-pressed', 'true')
await page.getByTestId('chat-input').fill('/reasoning')
await page.getByTestId('chat-input').press('Enter')
await expect(page.getByTestId('chat-reasoning-off')).toHaveAttribute('aria-pressed', 'false')
await page.getByTestId('chat-input').fill('/effort')
await page.getByTestId('chat-input').press('Enter')
await expect(page.getByTestId('chat-effort-menu')).toBeVisible()
await expect(page.getByTestId('chat-effort-default')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.getByTestId('chat-effort-menu')).toHaveCount(0)
await expect(page.getByTestId('chat-effort-chip')).toBeFocused()
})
})
@@ -132,6 +132,22 @@
"selectModelFirst": "Select a model first",
"placeholderPick": "Pick a model to start"
},
"reasoning": {
"effortLabel": "Effort",
"effortTitle": "Reasoning effort for this chat",
"default": "Model default",
"level": {
"none": "None",
"minimal": "Minimal",
"low": "Low",
"medium": "Medium",
"high": "High"
},
"offLabel": "No reasoning",
"offTitle": "Turn reasoning off for this chat: ask the model for no reasoning and hide any reasoning it sends",
"offToast": "Reasoning is off for this chat",
"onToast": "Reasoning is on for this chat"
},
"deleteAllDialog": {
"title": "Delete All Chats",
"message": "Delete all chats? This cannot be undone.",
@@ -213,6 +229,14 @@
"label": "Conversations",
"desc": "Open the list of your chats"
},
"effort": {
"label": "Reasoning effort",
"desc": "Choose how hard the model thinks"
},
"reasoning": {
"label": "Toggle reasoning",
"desc": "Turn reasoning off or on for this chat"
},
"assistant": {
"label": "Manage mode",
"desc": "Let this chat manage LocalAI"
@@ -0,0 +1,112 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { REASONING_EFFORTS } from './chatActions'
// The two reasoning controls in the Chat composer, both stored per chat:
// the effort chip opens a menu. "Model default" sends no reasoning_effort.
// the traces chip a toggle. On, the chat asks for reasoning_effort "none" and
// the thread drops any reasoning the model still sends.
// The ref gives the page open() for the /effort action.
const ReasoningChips = forwardRef(function ReasoningChips({ effort, off, onEffort, onOff }, ref) {
const { t } = useTranslation('chat')
const [open, setOpen] = useState(false)
const rootRef = useRef(null)
const chipRef = useRef(null)
const menuRef = useRef(null)
const choices = ['', ...REASONING_EFFORTS]
const current = off ? 'none' : (effort || '')
const nameOf = (v) => (v ? t(`reasoning.level.${v}`) : t('reasoning.default'))
const close = useCallback((restoreFocus = false) => {
setOpen(false)
if (restoreFocus) chipRef.current?.focus()
}, [])
const openMenu = useCallback(() => { if (!off) setOpen(true) }, [off])
useImperativeHandle(ref, () => ({ open: openMenu }), [openMenu])
useEffect(() => {
if (!open) return undefined
const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) close() }
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open, close])
useEffect(() => {
if (!open) return
const items = menuRef.current?.querySelectorAll('[role="menuitemradio"]')
items?.[Math.max(0, choices.indexOf(current))]?.focus()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const pick = (v) => { onEffort(v); close(true) }
const onMenuKey = (e) => {
const items = Array.from(menuRef.current?.querySelectorAll('[role="menuitemradio"]') || [])
const at = items.indexOf(document.activeElement)
if (e.key === 'ArrowDown') { e.preventDefault(); items[(at + 1) % items.length]?.focus() }
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(at + items.length - 1) % items.length]?.focus() }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true) }
else if (e.key === 'Tab') close()
}
return (
<span className="cx-chipset">
<span className="home-pick" ref={rootRef}>
<button
ref={chipRef}
type="button"
className="home-chip"
aria-haspopup="menu"
aria-expanded={open}
disabled={off}
title={t('reasoning.effortTitle')}
onClick={() => (open ? close() : openMenu())}
data-testid="chat-effort-chip"
>
<Icon name="brain" />
<span className="home-chip__text">{t('reasoning.effortLabel')}: {nameOf(current)}</span>
<Icon name="chevron-down" className="home-chip__caret" />
</button>
{open && (
<div
ref={menuRef}
className="home-menu cx-effort-menu"
role="menu"
aria-label={t('reasoning.effortTitle')}
onKeyDown={onMenuKey}
data-testid="chat-effort-menu"
>
{choices.map(v => (
<button
key={v || 'default'}
type="button"
role="menuitemradio"
aria-checked={v === current}
className="home-menu__item cx-effort-item"
onClick={() => pick(v)}
data-testid={`chat-effort-${v || 'default'}`}
>
<span>{nameOf(v)}</span>
{v === current && <Icon name="check" className="home-menu__check" />}
</button>
))}
</div>
)}
</span>
<button
type="button"
className="home-chip cx-chip-toggle"
aria-pressed={!!off}
onClick={() => { close(); onOff(!off) }}
title={t('reasoning.offTitle')}
data-testid="chat-reasoning-off"
>
<Icon name="eye-off" />
<span className="home-chip__text">{t('reasoning.offLabel')}</span>
</button>
</span>
)
})
export default ReasoningChips
+6
View File
@@ -4,11 +4,17 @@
export const CHAT_SLASH_GROUPS = ['chat', 'view']
// The reasoning_effort values the API accepts. An empty choice sends none.
export const REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high']
export const CHAT_SLASH_ACTIONS = [
{ id: 'model', cmd: '/model', icon: 'cube', group: 'chat', needs: 'models' },
{ id: 'new', cmd: '/new', icon: 'plus', group: 'chat' },
{ id: 'chats', cmd: '/chats', icon: 'history', group: 'chat' },
{ id: 'assistant', cmd: '/assistant', icon: 'user-shield', group: 'chat', needs: 'admin' },
// /reasoning comes first so that Enter on "/reasoning" picks it, not "Reasoning effort".
{ id: 'reasoning', cmd: '/reasoning', icon: 'eye-off', group: 'chat' },
{ id: 'effort', cmd: '/effort', icon: 'brain', group: 'chat' },
{ id: 'canvas', cmd: '/canvas', icon: 'columns', group: 'view' },
{ id: 'find', cmd: '/find', icon: 'search', group: 'view', needs: 'thread' },
{ id: 'settings', cmd: '/settings', icon: 'sliders', group: 'view' },
+18 -7
View File
@@ -128,6 +128,8 @@ function saveChats(chats, activeChatId) {
temperature: chat.temperature,
topP: chat.topP,
topK: chat.topK,
reasoningEffort: chat.reasoningEffort,
reasoningOff: chat.reasoningOff,
tokenUsage: chat.tokenUsage,
contextSize: chat.contextSize,
createdAt: chat.createdAt,
@@ -161,6 +163,10 @@ function createNewChat(model = '', systemPrompt = '', mcpMode = false) {
temperature: null,
topP: null,
topK: null,
// '' leaves reasoning_effort to the model config. reasoningOff asks for
// 'none' and hides reasoning traces in the thread.
reasoningEffort: '',
reasoningOff: false,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
contextSize: null,
createdAt: Date.now(),
@@ -282,6 +288,8 @@ export function useChat(initialModel = '') {
const temperature = activeChat.temperature
const topP = activeChat.topP
const topK = activeChat.topK
const hideReasoning = !!activeChat.reasoningOff
const showReasoning = (text) => { if (!hideReasoning) setStreamingReasoning(text) }
// Build user message content
let messageContent
@@ -374,6 +382,9 @@ export function useChat(initialModel = '') {
if (temperature !== null && temperature !== undefined) requestBody.temperature = temperature
if (topP !== null && topP !== undefined) requestBody.top_p = topP
if (topK !== null && topK !== undefined) requestBody.top_k = topK
// reasoning_effort is honoured per request. 'none' also turns thinking off.
const reasoningEffort = hideReasoning ? 'none' : activeChat.reasoningEffort
if (reasoningEffort) requestBody.reasoning_effort = reasoningEffort
// contextSize is the model's input+output window, not an
// output cap. Backends bound generation at remaining context
// automatically; Anthropic translate mode supplies its own
@@ -473,7 +484,7 @@ export function useChat(initialModel = '') {
if (eventData.content) {
reasoningContent += eventData.content
tokenCountRef.current += Math.ceil(eventData.content.length / 4)
setStreamingReasoning(reasoningContent)
showReasoning(reasoningContent)
updateTps()
}
break
@@ -518,7 +529,7 @@ export function useChat(initialModel = '') {
const { regularContent, thinkingContent } = extractThinking(assistantContent)
if (thinkingContent) {
reasoningContent = thinkingContent
setStreamingReasoning(reasoningContent)
showReasoning(reasoningContent)
}
setStreamingContent(regularContent)
} else {
@@ -546,7 +557,7 @@ export function useChat(initialModel = '') {
if (thinkingContent && !reasoningContent) reasoningContent = thinkingContent
}
if (reasoningContent) {
if (reasoningContent && !hideReasoning) {
newMessages.unshift({ role: 'thinking', content: reasoningContent, expanded: true })
}
if (finalContent) {
@@ -644,7 +655,7 @@ export function useChat(initialModel = '') {
hasReasoningFromAPI = true
reasoningContent += delta.reasoning
tokenCountRef.current++
setStreamingReasoning(reasoningContent)
showReasoning(reasoningContent)
updateTps()
}
@@ -699,7 +710,7 @@ export function useChat(initialModel = '') {
const lastOpen = Math.max(rawContent.lastIndexOf('<thinking>'), rawContent.lastIndexOf('<think>'), rawContent.lastIndexOf('<|channel>thought'))
if (lastOpen >= 0) {
const partial = rawContent.slice(lastOpen).replace(/<thinking>|<think>|<\|channel>thought/, '')
setStreamingReasoning(partial)
showReasoning(partial)
const beforeThink = rawContent.slice(0, lastOpen)
const { regularContent: contentBeforeThink } = extractThinking(beforeThink)
setStreamingContent(contentBeforeThink)
@@ -707,7 +718,7 @@ export function useChat(initialModel = '') {
setStreamingContent(regularContent)
}
} else {
setStreamingReasoning(reasoningContent)
showReasoning(reasoningContent)
setStreamingContent(regularContent)
}
} else {
@@ -806,7 +817,7 @@ export function useChat(initialModel = '') {
if (thinkingContent && !reasoningContent) reasoningContent = thinkingContent
}
if (reasoningContent) {
if (reasoningContent && !hideReasoning) {
newMessages.push({ role: 'thinking', content: reasoningContent, expanded: true })
}
if (finalContent) {
+23 -2
View File
@@ -29,6 +29,7 @@ import ChatHeader from '../components/chat/ChatHeader'
// eslint-disable-next-line no-unused-vars
import ShortcutsDialog from '../components/chat/ShortcutsDialog'
// eslint-disable-next-line no-unused-vars
import ReasoningChips from '../components/chat/ReasoningChips'
import ChatSettingsSheet from '../components/chat/ChatSettingsSheet'
// eslint-disable-next-line no-unused-vars
import FindBar from '../components/chat/FindBar'
@@ -190,6 +191,7 @@ export default function Chat() {
const [scrolledUp, setScrolledUp] = useState(false)
const chatsMenuRef = useRef(null)
const pickerRef = useRef(null)
const effortRef = useRef(null)
const { models: chatModels, loading: chatModelsLoading } = useModels(CAP_CHAT)
const [fitOpenCount, setFitOpenCount] = useState(0)
const modelNames = useMemo(() => chatModels.map(m => m.id), [chatModels])
@@ -1000,6 +1002,11 @@ export default function Chat() {
/>
)
const toggleReasoningOff = (reasoningOff) => {
updateChatSettings(activeChat.id, { reasoningOff })
addToast(t(reasoningOff ? 'reasoning.offToast' : 'reasoning.onToast'), 'info', 2000)
}
const mcp = (
<UnifiedMCPDropdown
serverMCPAvailable={mcpAvailable}
@@ -1039,7 +1046,15 @@ export default function Chat() {
/>
)
const canvasChip = (
const composerChips = (
<>
<ReasoningChips
ref={effortRef}
effort={activeChat.reasoningEffort}
off={activeChat.reasoningOff}
onEffort={(reasoningEffort) => updateChatSettings(activeChat.id, { reasoningEffort })}
onOff={toggleReasoningOff}
/>
<span className="cx-chipset">
<button
type="button"
@@ -1064,6 +1079,7 @@ export default function Chat() {
</button>
)}
</span>
</>
)
const moreItems = [
@@ -1082,6 +1098,11 @@ export default function Chat() {
case 'model': pickerRef.current?.open(); break
case 'new': addChat(activeChat.model); break
case 'chats': chatsMenuRef.current?.open(); break
case 'effort': {
effortRef.current?.open()
break
}
case 'reasoning': toggleReasoningOff(!activeChat.reasoningOff); break
case 'assistant': updateChatSettings(activeChat.id, { localaiAssistant: !activeChat.localaiAssistant }); break
case 'canvas': toggleCanvasMode(); break
case 'settings': setShowSettings(true); break
@@ -1212,7 +1233,7 @@ export default function Chat() {
textareaRef={textareaRef}
picker={picker}
mcp={mcp}
chips={canvasChip}
chips={composerChips}
files={files}
onRemoveFile={(f) => setFiles(prev => prev.filter(x => x !== f))}
onAttach={attachFiles}
+9
View File
@@ -964,3 +964,12 @@
.cx-dock .home-menu__list { max-height: min(60vh, 420px); }
}
.cx-thread[hidden] { display: none; }
/* Reasoning effort menu: a short list of buttons in the shared menu frame. */
.cx-effort-menu { min-width: 180px; }
.cx-effort-item { width: 100%; border: 0; background: none; font: inherit; text-align: left; cursor: pointer; }
.cx-effort-item:hover,
.cx-effort-item:focus-visible { background: var(--dk-hover); color: var(--dk-text); outline: none; }
@media (max-width: 640px) {
.cx-chipset .home-chip__text { max-width: 22ch; }
}