mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
[ui] Fold the No reasoning chip into the effort picker (#12716)
fix(ui): fold the "No reasoning" chip into the effort picker The chat composer had two controls for one idea: the effort picker with a "None" level, and a separate "No reasoning" chip. The chip also hid reasoning traces that a model sends despite reasoning_effort "none", which "None" did not. The chip disabled the picker while it was on. Choosing "None" now hides those traces and stays selectable, so the chip, its toasts and the /reasoning slash action are removed. A chat saved with the old reasoningOff flag loads with effort "none", so it keeps hiding reasoning. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
d2eacfb797
commit
1d0a3227d7
7 files changed
+37
-60
No files matched your search
@@ -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', '/reasoning', '/effort', '/canvas', '/find', '/settings', '/export', '/clear'])
|
||||
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/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', '/reasoning', '/effort', '/canvas', '/settings'])
|
||||
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/effort', '/canvas', '/settings'])
|
||||
})
|
||||
|
||||
test('"/" from outside a text field starts a command', async ({ page }) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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.
|
||||
// The composer has one reasoning control, stored per chat: the effort select.
|
||||
// "None" asks for no reasoning and hides what the model still sends. It is
|
||||
// also the /effort slash action.
|
||||
|
||||
const sendWith = async (page, answer) => {
|
||||
let body = null
|
||||
@@ -40,12 +41,13 @@ test.describe('Chat reasoning controls', () => {
|
||||
expect(body.reasoning_effort).toBe('high')
|
||||
})
|
||||
|
||||
test('the toggle asks for "none" and hides reasoning the model still sends', async ({ page }) => {
|
||||
test('"None" 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 chip = page.getByTestId('chat-effort-chip')
|
||||
await chip.click()
|
||||
await page.getByTestId('chat-effort-none').click()
|
||||
await expect(chip).toContainText('None')
|
||||
await expect(chip).toBeEnabled()
|
||||
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()
|
||||
@@ -54,14 +56,17 @@ test.describe('Chat reasoning controls', () => {
|
||||
expect(history.some(m => m.role === 'thinking')).toBe(false)
|
||||
})
|
||||
|
||||
test('the slash actions toggle reasoning and open the effort select', async ({ page }) => {
|
||||
test('a chat saved with the old reasoningOff flag still hides reasoning', async ({ page }) => {
|
||||
const chat = { ...chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello')), reasoningOff: true }
|
||||
await openChat(page, [chat])
|
||||
await expect(page.getByTestId('chat-effort-chip')).toContainText('None')
|
||||
const body = await sendWith(page, stream({ reasoning: 'secret plan' }, { content: 'The answer.' }))
|
||||
expect(body.reasoning_effort).toBe('none')
|
||||
await expect(page.getByText('secret plan')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the /effort slash action opens 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()
|
||||
|
||||
@@ -142,11 +142,7 @@
|
||||
"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",
|
||||
@@ -233,10 +229,6 @@
|
||||
"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"
|
||||
|
||||
@@ -3,26 +3,25 @@ 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 reasoning effort chip in the Chat composer, stored per chat. It opens a
|
||||
// menu. "Model default" sends no reasoning_effort. "None" sends "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 ReasoningChips = forwardRef(function ReasoningChips({ effort, onEffort }, 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 current = 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])
|
||||
const openMenu = useCallback(() => setOpen(true), [])
|
||||
useImperativeHandle(ref, () => ({ open: openMenu }), [openMenu])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,7 +58,6 @@ const ReasoningChips = forwardRef(function ReasoningChips({ effort, off, onEffor
|
||||
className="home-chip"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={off}
|
||||
title={t('reasoning.effortTitle')}
|
||||
onClick={() => (open ? close() : openMenu())}
|
||||
data-testid="chat-effort-chip"
|
||||
@@ -94,17 +92,6 @@ const ReasoningChips = forwardRef(function ReasoningChips({ effort, off, onEffor
|
||||
</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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,8 +12,6 @@ export const CHAT_SLASH_ACTIONS = [
|
||||
{ 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' },
|
||||
|
||||
+9
-6
@@ -104,6 +104,11 @@ function loadChats() {
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored)
|
||||
if (data && Array.isArray(data.chats)) {
|
||||
// Older chats stored a separate reasoningOff flag. It now means
|
||||
// reasoning_effort 'none'.
|
||||
data.chats = data.chats.map(({ reasoningOff, ...chat }) => (
|
||||
reasoningOff ? { ...chat, reasoningEffort: 'none' } : chat
|
||||
))
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -129,7 +134,6 @@ function saveChats(chats, activeChatId) {
|
||||
topP: chat.topP,
|
||||
topK: chat.topK,
|
||||
reasoningEffort: chat.reasoningEffort,
|
||||
reasoningOff: chat.reasoningOff,
|
||||
tokenUsage: chat.tokenUsage,
|
||||
contextSize: chat.contextSize,
|
||||
createdAt: chat.createdAt,
|
||||
@@ -163,10 +167,9 @@ 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.
|
||||
// '' leaves reasoning_effort to the model config. 'none' also hides
|
||||
// reasoning traces in the thread.
|
||||
reasoningEffort: '',
|
||||
reasoningOff: false,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
contextSize: null,
|
||||
createdAt: Date.now(),
|
||||
@@ -288,7 +291,7 @@ export function useChat(initialModel = '') {
|
||||
const temperature = activeChat.temperature
|
||||
const topP = activeChat.topP
|
||||
const topK = activeChat.topK
|
||||
const hideReasoning = !!activeChat.reasoningOff
|
||||
const hideReasoning = activeChat.reasoningEffort === 'none'
|
||||
const showReasoning = (text) => { if (!hideReasoning) setStreamingReasoning(text) }
|
||||
|
||||
// Build user message content
|
||||
@@ -383,7 +386,7 @@ export function useChat(initialModel = '') {
|
||||
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
|
||||
const reasoningEffort = 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
|
||||
|
||||
@@ -1002,11 +1002,6 @@ 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}
|
||||
@@ -1051,9 +1046,7 @@ export default function Chat() {
|
||||
<ReasoningChips
|
||||
ref={effortRef}
|
||||
effort={activeChat.reasoningEffort}
|
||||
off={activeChat.reasoningOff}
|
||||
onEffort={(reasoningEffort) => updateChatSettings(activeChat.id, { reasoningEffort })}
|
||||
onOff={toggleReasoningOff}
|
||||
/>
|
||||
<span className="cx-chipset">
|
||||
<button
|
||||
@@ -1102,7 +1095,6 @@ export default function Chat() {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user