diff --git a/core/http/react-ui/e2e/chat-composer.spec.js b/core/http/react-ui/e2e/chat-composer.spec.js index 7e1b15238..fedd45a46 100644 --- a/core/http/react-ui/e2e/chat-composer.spec.js +++ b/core/http/react-ui/e2e/chat-composer.spec.js @@ -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 }) => { diff --git a/core/http/react-ui/e2e/chat-reasoning.spec.js b/core/http/react-ui/e2e/chat-reasoning.spec.js index ddd7a6324..d184f499f 100644 --- a/core/http/react-ui/e2e/chat-reasoning.spec.js +++ b/core/http/react-ui/e2e/chat-reasoning.spec.js @@ -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() diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index 6dfbed355..b714380ad 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -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" diff --git a/core/http/react-ui/src/components/chat/ReasoningChips.jsx b/core/http/react-ui/src/components/chat/ReasoningChips.jsx index cd6855169..cff9c96e4 100644 --- a/core/http/react-ui/src/components/chat/ReasoningChips.jsx +++ b/core/http/react-ui/src/components/chat/ReasoningChips.jsx @@ -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 )} - ) }) diff --git a/core/http/react-ui/src/components/chat/chatActions.js b/core/http/react-ui/src/components/chat/chatActions.js index 8e399800e..efc1b57f5 100644 --- a/core/http/react-ui/src/components/chat/chatActions.js +++ b/core/http/react-ui/src/components/chat/chatActions.js @@ -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' }, diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index 292a28025..842d8a373 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -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 diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 98c90075a..ab420bc90 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -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 = ( updateChatSettings(activeChat.id, { reasoningEffort })} - onOff={toggleReasoningOff} />