diff --git a/core/http/react-ui/e2e/chat-composer.spec.js b/core/http/react-ui/e2e/chat-composer.spec.js index 48c85c27d..7e1b15238 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', '/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 }) => { diff --git a/core/http/react-ui/e2e/chat-reasoning.spec.js b/core/http/react-ui/e2e/chat-reasoning.spec.js new file mode 100644 index 000000000..ddd7a6324 --- /dev/null +++ b/core/http/react-ui/e2e/chat-reasoning.spec.js @@ -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() + }) +}) diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index a84c6c4f4..6dfbed355 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -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" diff --git a/core/http/react-ui/src/components/chat/ReasoningChips.jsx b/core/http/react-ui/src/components/chat/ReasoningChips.jsx new file mode 100644 index 000000000..cd6855169 --- /dev/null +++ b/core/http/react-ui/src/components/chat/ReasoningChips.jsx @@ -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 ( + + + (open ? close() : openMenu())} + data-testid="chat-effort-chip" + > + + {t('reasoning.effortLabel')}: {nameOf(current)} + + + {open && ( + + {choices.map(v => ( + pick(v)} + data-testid={`chat-effort-${v || 'default'}`} + > + {nameOf(v)} + {v === current && } + + ))} + + )} + + { close(); onOff(!off) }} + title={t('reasoning.offTitle')} + data-testid="chat-reasoning-off" + > + + {t('reasoning.offLabel')} + + + ) +}) + +export default ReasoningChips diff --git a/core/http/react-ui/src/components/chat/chatActions.js b/core/http/react-ui/src/components/chat/chatActions.js index 9052bc8cb..8e399800e 100644 --- a/core/http/react-ui/src/components/chat/chatActions.js +++ b/core/http/react-ui/src/components/chat/chatActions.js @@ -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' }, diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index 16f99f649..292a28025 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -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(''), rawContent.lastIndexOf(''), rawContent.lastIndexOf('<|channel>thought')) if (lastOpen >= 0) { const partial = rawContent.slice(lastOpen).replace(/||<\|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) { diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 3a9cd400d..98c90075a 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -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 = ( ) - const canvasChip = ( + const composerChips = ( + <> + updateChatSettings(activeChat.id, { reasoningEffort })} + onOff={toggleReasoningOff} + /> )} + > ) 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} diff --git a/core/http/react-ui/src/pages/chat.css b/core/http/react-ui/src/pages/chat.css index d1a06148b..0db2673f2 100644 --- a/core/http/react-ui/src/pages/chat.css +++ b/core/http/react-ui/src/pages/chat.css @@ -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; } +}