This commit is contained in:
chriscrosstalk 2026-07-11 01:42:25 -05:00 committed by GitHub
commit d6ad7f698b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 166 additions and 15 deletions

View File

@ -144,13 +144,21 @@ export default class OllamaController {
logger.debug(`[OllamaController] Large system prompt (~${estimatedSystemTokens} tokens), requesting num_ctx: ${numCtx}`)
}
// Check if the model supports "thinking" capability for enhanced response generation
// Check if the model supports "thinking" capability for enhanced response generation.
// Thinking is only enabled when the model supports it AND the user wants it: the explicit
// per-request preference wins, otherwise the global default (ai.autoThinking, default OFF).
// If gpt-oss model, it requires a text param for "think" https://docs.ollama.com/api/chat
const thinkingCapability = await this.ollamaService.checkModelHasThinking(reqData.model)
const think: boolean | 'medium' = thinkingCapability ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false
let thinkingEnabled = false
if (thinkingCapability) {
thinkingEnabled = reqData.think ?? ((await KVStore.getValue('ai.autoThinking')) ?? false)
}
const think: boolean | 'medium' =
thinkingEnabled ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false
// Separate sessionId from the Ollama request payload — Ollama rejects unknown fields
const { sessionId, ...ollamaRequest } = reqData
// Separate sessionId and the resolved thinking preference from the Ollama request payload —
// Ollama rejects unknown fields, and `think` is re-derived above (not forwarded raw).
const { sessionId, think: _thinkPref, ...ollamaRequest } = reqData
// Save user message to DB before streaming if sessionId provided
let userContent: string | null = null
@ -173,6 +181,7 @@ export default class OllamaController {
const stream = await this.ollamaService.chatStream({
...ollamaRequest,
think,
thinkingCapable: thinkingCapability,
numCtx,
signal: abortController.signal,
})
@ -207,7 +216,7 @@ export default class OllamaController {
}
// Non-streaming (legacy) path
const result = await this.ollamaService.chat({ ...ollamaRequest, think, numCtx })
const result = await this.ollamaService.chat({ ...ollamaRequest, think, thinkingCapable: thinkingCapability, numCtx })
if (sessionId && result?.message?.content) {
await this.chatService.addMessage(sessionId, 'assistant', result.message.content)
@ -387,7 +396,14 @@ export default class OllamaController {
}
async installedModels({ }: HttpContext) {
return await this.ollamaService.getModels()
const models = await this.ollamaService.getModels()
// Enrich each model with its thinking capability so the chat picker knows which models
// to show the per-model thinking toggle for. checkModelHasThinking memoizes /api/show
// results, so this stays cheap on repeat loads. Best-effort per model.
const thinking = await Promise.all(
models.map((m) => this.ollamaService.checkModelHasThinking(m.name))
)
return models.map((m, i) => ({ ...m, thinking: thinking[i] }))
}
/**

View File

@ -66,6 +66,7 @@ export default class SettingsController {
const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')
const remoteOllamaUrl = await KVStore.getValue('ai.remoteOllamaUrl')
const ollamaFlashAttention = await KVStore.getValue('ai.ollamaFlashAttention')
const autoThinking = await KVStore.getValue('ai.autoThinking')
return inertia.render('settings/models', {
models: {
availableModels: availableModels?.models || [],
@ -75,6 +76,7 @@ export default class SettingsController {
aiAssistantCustomName: aiAssistantCustomName ?? '',
remoteOllamaUrl: remoteOllamaUrl ?? '',
ollamaFlashAttention: ollamaFlashAttention ?? true,
autoThinking: autoThinking ?? false,
},
},
})

View File

@ -43,6 +43,9 @@ type ChatInput = {
model: string
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
think?: boolean | 'medium'
// Whether the target model supports thinking. Lets chat()/chatStream() tell "capable but
// disabled" (send reasoning_effort:'none') apart from "not capable" (send nothing).
thinkingCapable?: boolean
stream?: boolean
numCtx?: number
// Aborts the upstream request when the client disconnects, so an abandoned generation
@ -57,6 +60,9 @@ export class OllamaService {
private initPromise: Promise<void> | null = null
private isOllamaNative: boolean | null = null
private activeDownloads: Map<string, Promise<{ success: boolean; message: string; retryable?: boolean }>> = new Map()
// Memoized `thinking` capability per model name (see checkModelHasThinking). Only successful
// /api/show lookups are cached; transient failures are left uncached so they can be retried.
private thinkingCapabilityCache: Map<string, boolean> = new Map()
constructor() {}
@ -332,6 +338,15 @@ export class OllamaService {
if (chatRequest.think) {
params.think = chatRequest.think
}
// The /v1 (OpenAI-compat) endpoint ignores `think`; `reasoning_effort` is the actual lever.
// Only touch it for thinking-capable models so non-Ollama backends never get an unexpected
// param. gpt-oss requires an explicit level; a capable-but-disabled model gets 'none' to
// suppress thinking (capable models default thinking ON otherwise, so think===true is a no-op).
if (chatRequest.think === 'medium') {
params.reasoning_effort = 'medium'
} else if (chatRequest.thinkingCapable && chatRequest.think === false) {
params.reasoning_effort = 'none'
}
if (chatRequest.numCtx) {
params.num_ctx = chatRequest.numCtx
}
@ -365,6 +380,15 @@ export class OllamaService {
if (chatRequest.think) {
params.think = chatRequest.think
}
// The /v1 (OpenAI-compat) endpoint ignores `think`; `reasoning_effort` is the actual lever.
// Only touch it for thinking-capable models so non-Ollama backends never get an unexpected
// param. gpt-oss requires an explicit level; a capable-but-disabled model gets 'none' to
// suppress thinking (capable models default thinking ON otherwise, so think===true is a no-op).
if (chatRequest.think === 'medium') {
params.reasoning_effort = 'medium'
} else if (chatRequest.thinkingCapable && chatRequest.think === false) {
params.reasoning_effort = 'none'
}
if (chatRequest.numCtx) {
params.num_ctx = chatRequest.numCtx
}
@ -444,13 +468,22 @@ export class OllamaService {
await this._ensureDependencies()
if (!this.baseUrl) return false
// A model's capabilities don't change at runtime, so memoize the /api/show result. Without
// this, loading the chat picker fires one /api/show per installed model and every chat send
// fires another — this collapses those to a single call per model per process.
const cached = this.thinkingCapabilityCache.get(modelName)
if (cached !== undefined) return cached
try {
const response = await axios.post(
`${this.baseUrl}/api/show`,
{ model: modelName },
{ timeout: 5000 }
)
return Array.isArray(response.data?.capabilities) && response.data.capabilities.includes('thinking')
const hasThinking =
Array.isArray(response.data?.capabilities) && response.data.capabilities.includes('thinking')
this.thinkingCapabilityCache.set(modelName, hasThinking)
return hasThinking
} catch {
// Non-Ollama backends don't expose /api/show — assume no thinking support
return false

View File

@ -11,6 +11,9 @@ export const chatSchema = vine.compile(
),
stream: vine.boolean().optional(),
sessionId: vine.number().positive().optional(),
// Effective per-request thinking preference (per-model override or global default),
// resolved client-side. Omitted -> server falls back to the ai.autoThinking KV default.
think: vine.boolean().optional(),
})
)

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'system.internetStatusTestUrl', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'system.internetStatusTestUrl', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'ai.autoThinking', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow'];

View File

@ -4,9 +4,21 @@ import { useState } from 'react'
interface InfoTooltipProps {
text: string
className?: string
// Which side of the icon the tooltip pops toward. Defaults to 'top' (existing behavior);
// use 'bottom' when the icon sits near the top of the viewport so it isn't clipped.
position?: 'top' | 'bottom'
// Horizontal anchoring. 'center' (default) centers the bubble on the icon. Use 'right' when
// the icon sits near the right edge so the bubble expands leftward into open space instead of
// being squeezed against the viewport edge (which forces one-word-per-line wrapping).
align?: 'center' | 'right'
}
export default function InfoTooltip({ text, className = '' }: InfoTooltipProps) {
export default function InfoTooltip({
text,
className = '',
position = 'top',
align = 'center',
}: InfoTooltipProps) {
const [isVisible, setIsVisible] = useState(false)
return (
@ -23,10 +35,24 @@ export default function InfoTooltip({ text, className = '' }: InfoTooltipProps)
<IconInfoCircle className="w-4 h-4" />
</button>
{isVisible && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50">
<div className="bg-desert-stone-dark text-white text-xs rounded-lg px-3 py-2 max-w-xs whitespace-normal shadow-lg">
<div
className={`absolute z-50 ${position === 'bottom' ? 'top-full mt-2' : 'bottom-full mb-2'} ${
align === 'right' ? 'right-0' : 'left-1/2 -translate-x-1/2'
}`}
>
<div
className={`bg-desert-stone-dark text-white text-xs rounded-lg px-3 py-2 whitespace-normal shadow-lg ${
align === 'right' ? 'w-64' : 'max-w-xs'
}`}
>
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-desert-stone-dark" />
<div
className={`absolute border-4 border-transparent ${
position === 'bottom'
? 'bottom-full border-b-desert-stone-dark'
: 'top-full border-t-desert-stone-dark'
} ${align === 'right' ? 'right-3' : 'left-1/2 -translate-x-1/2'}`}
/>
</div>
</div>
)}

View File

@ -12,6 +12,8 @@ import classNames from '~/lib/classNames'
import { IconX } from '@tabler/icons-react'
import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'
import { useSystemSetting } from '~/hooks/useSystemSetting'
import Switch from '~/components/inputs/Switch'
import InfoTooltip from '~/components/InfoTooltip'
interface ChatProps {
enabled: boolean
@ -57,6 +59,11 @@ export default function Chat({
const { data: lastModelSetting } = useSystemSetting({ key: 'chat.lastModel', enabled })
const { data: remoteOllamaUrlSetting } = useSystemSetting({ key: 'ai.remoteOllamaUrl', enabled })
const { data: autoThinkingSetting } = useSystemSetting({ key: 'ai.autoThinking', enabled })
// Global default for models the user hasn't explicitly toggled. Coerce defensively — KV
// booleans have historically round-tripped as strings.
const autoThinkingDefault =
autoThinkingSetting?.value === true || autoThinkingSetting?.value === 'true'
const { data: remoteStatus } = useQuery({
queryKey: ['remoteOllamaStatus'],
@ -72,6 +79,38 @@ export default function Chat({
select: (data) => data || [],
})
// Per-model thinking overrides, remembered client-side (localStorage, keyed by model name).
// An entry here means the user explicitly toggled thinking for that model; absent means fall
// back to the global default (ai.autoThinking). Seeded from localStorage when models load.
const [thinkingOverrides, setThinkingOverrides] = useState<Record<string, boolean>>({})
useEffect(() => {
const next: Record<string, boolean> = {}
for (const m of installedModels) {
try {
const stored = localStorage.getItem(`nomad:thinking:${m.name}`)
if (stored !== null) next[m.name] = stored === 'true'
} catch {}
}
setThinkingOverrides(next)
}, [installedModels])
const selectedModelSupportsThinking =
installedModels.find((m) => m.name === selectedModel)?.thinking === true
// Effective thinking preference for a model: explicit override wins, else the global default.
const effectiveThinking = useCallback(
(model: string): boolean =>
model in thinkingOverrides ? thinkingOverrides[model] : autoThinkingDefault,
[thinkingOverrides, autoThinkingDefault]
)
const setModelThinking = useCallback((model: string, value: boolean) => {
setThinkingOverrides((prev) => ({ ...prev, [model]: value }))
try {
localStorage.setItem(`nomad:thinking:${model}`, String(value))
} catch {}
}, [])
const { data: chatSuggestions, isLoading: chatSuggestionsLoading } = useQuery<string[]>({
queryKey: ['chatSuggestions'],
queryFn: async ({ signal }) => {
@ -102,6 +141,7 @@ export default function Chat({
model: string
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
sessionId?: number
think?: boolean
}) => api.sendChatMessage({ ...request, stream: false }),
onSuccess: async (data) => {
if (!data || !activeSessionId) {
@ -323,7 +363,7 @@ export default function Chat({
try {
await api.streamChatMessage(
{ model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined },
{ model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined, think: effectiveThinking(selectedModel) },
(chunkContent, chunkThinking, done) => {
if (chunkThinking.length > 0 && thinkingStartTime === null) {
thinkingStartTime = Date.now()
@ -414,10 +454,11 @@ export default function Chat({
model: selectedModel || 'llama3.2',
messages: chatMessages,
sessionId: sessionId ? Number(sessionId) : undefined,
think: effectiveThinking(selectedModel),
})
}
},
[activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled]
[activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled, effectiveThinking]
)
return (
@ -494,6 +535,21 @@ export default function Chat({
</select>
)}
</div>
{selectedModelSupportsThinking && (
<div className="flex items-center">
<span className="text-sm text-text-secondary select-none">Thinking:</span>
<InfoTooltip
position="bottom"
align="right"
text="When on, this model works through its reasoning before answering. Slower, but often better on tricky questions. Your choice is remembered for this model; the default for other models is set in AI Assistant settings."
/>
<Switch
id="chat-thinking-toggle"
checked={effectiveThinking(selectedModel)}
onChange={(v) => setModelThinking(selectedModel, v)}
/>
</div>
)}
{isInModal && (
<button
onClick={() => {

View File

@ -26,7 +26,7 @@ export default function ModelsPage(props: {
models: {
availableModels: NomadOllamaModel[]
installedModels: NomadInstalledModel[]
settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string; remoteOllamaUrl: string; ollamaFlashAttention: boolean }
settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string; remoteOllamaUrl: string; ollamaFlashAttention: boolean; autoThinking: boolean }
}
}) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
@ -98,6 +98,7 @@ export default function ModelsPage(props: {
const [ollamaFlashAttention, setOllamaFlashAttention] = useState(
props.models.settings.ollamaFlashAttention
)
const [autoThinking, setAutoThinking] = useState(props.models.settings.autoThinking)
const [aiAssistantCustomName, setAiAssistantCustomName] = useState(
props.models.settings.aiAssistantCustomName
)
@ -320,6 +321,15 @@ export default function ModelsPage(props: {
label="Flash Attention"
description="Enables OLLAMA_FLASH_ATTENTION=1 for improved memory efficiency. Disable if you experience instability. Takes effect after reinstalling the AI Assistant."
/>
<Switch
checked={autoThinking}
onChange={(newVal) => {
setAutoThinking(newVal)
updateSettingMutation.mutate({ key: 'ai.autoThinking', value: newVal })
}}
label="Use thinking automatically when a model supports it"
description="Sets the default for models that can think. You can still turn thinking on or off for an individual model in the chat window."
/>
<Input
name="aiAssistantCustomName"
label="Assistant Name"

View File

@ -38,6 +38,7 @@ export const KV_STORE_SCHEMA = {
'gpu.type': 'string',
'ai.remoteOllamaUrl': 'string',
'ai.ollamaFlashAttention': 'boolean',
'ai.autoThinking': 'boolean',
'ai.amdGpuAcceleration': 'boolean',
'ai.amdHsaOverride': 'string',
'ai.autoFixGpuPassthrough': 'boolean',

View File

@ -33,6 +33,8 @@ export type OllamaChatRequest = {
messages: OllamaChatMessage[]
stream?: boolean
sessionId?: number
// Effective thinking preference for this request (per-model override or global default).
think?: boolean
}
export type OllamaChatResponse = {
@ -50,6 +52,8 @@ export type NomadInstalledModel = {
size: number
digest?: string
details?: Record<string, any>
// Whether the model supports "thinking" (set by the installed-models endpoint enrichment).
thinking?: boolean
}
export type NomadChatResponse = {