feat(AI): specify a model for ancillary tasks (#1244)

Allows users to optionally specify a model to use for ancillary tasks (chat suggestions, chat titles, etc.) instead of strictly using the last used model. This means aesthetic/non-critical work can be passed to a small, lightweight model instead of a heavy reasoning model used for chats.

If a model is not selected in the AI Assistant settings (`/settings/models`), the existing behavior of using the last used model is retained.
This commit is contained in:
Jake Turner 2026-08-13 21:30:04 -07:00 committed by GitHub
parent aff56ad4a6
commit 4b2b3b5ebe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 197 additions and 6 deletions

View File

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

View File

@ -6,12 +6,64 @@ import { DateTime } from 'luxon'
import { inject } from '@adonisjs/core'
import { OllamaService } from './ollama_service.js'
import { SYSTEM_PROMPTS } from '../../constants/ollama.js'
import { toTitleCase } from '../utils/misc.js'
import { pickTasksModel, toTitleCase } from '../utils/misc.js'
@inject()
export class ChatService {
constructor(private ollamaService: OllamaService) {}
/**
* The model to use for ancillary work chat titles and chat suggestions.
*
* Prefers the user's `ai.tasksModel` setting so a 30B reasoning model isn't
* spending seconds "thinking" to produce a three-word sidebar title. Falls
* back to `fallback` when the setting is unset (the default, which preserves
* the previous behaviour) or when the configured model is no longer
* installed. `installed` is passed in by callers that already listed models,
* to avoid a second round-trip.
*/
private async resolveTasksModel(
fallback: string | null,
installed?: { name: string }[]
): Promise<string | null> {
let configured: string | null = null
try {
configured = await KVStore.getValue('ai.tasksModel')
} catch (error) {
logger.error(
`[ChatService] Failed to read ai.tasksModel: ${error instanceof Error ? error.message : error}`
)
return fallback
}
if (!configured?.trim()) {
return fallback
}
let models = installed
if (!models) {
try {
models = await this.ollamaService.getModels()
} catch (error) {
logger.error(
`[ChatService] Failed to list models while resolving the tasks model: ${error instanceof Error ? error.message : error}`
)
return fallback
}
}
const { model, staleConfigured } = pickTasksModel(
configured,
(models ?? []).map((m) => m.name),
fallback
)
if (staleConfigured) {
logger.warn(
`[ChatService] Configured tasks model "${staleConfigured}" is not installed; falling back to "${fallback ?? 'none'}"`
)
}
return model
}
async getAllSessions() {
try {
const sessions = await ChatSession.query().orderBy('updated_at', 'desc')
@ -37,11 +89,12 @@ export class ChatService {
return [] // If no models are available, return empty suggestions
}
// Prefer the user's selected chat model. Fall back to the smallest
// The user's dedicated tasks model wins when set — suggestions are short
// aesthetic prompts that don't benefit from a flagship model. Otherwise
// prefer the user's selected chat model, and fall back to the smallest
// installed model — picking the largest by file size is unsafe: if any
// installed model exceeds available VRAM (e.g. llama3.1:405b on a 96 GB
// GPU), Ollama spends minutes trying to load it and the request 500s.
// Suggestions are short prompts that don't benefit from a flagship model.
const lastModel = await KVStore.getValue('chat.lastModel')
const preferred = lastModel ? models.find((m) => m.name === lastModel) : undefined
const chosen =
@ -52,8 +105,10 @@ export class ChatService {
return []
}
const model = (await this.resolveTasksModel(chosen.name, models)) ?? chosen.name
const response = await this.ollamaService.chat({
model: chosen.name,
model,
messages: [
{
role: 'user',
@ -243,8 +298,12 @@ export class ChatService {
try {
let title: string
// Titles are aesthetic work; route them to the tasks model when one is
// configured rather than the chat model that just answered.
const titleModel = (await this.resolveTasksModel(model)) ?? model
const response = await this.ollamaService.chat({
model,
model: titleModel,
messages: [
{ role: 'system', content: SYSTEM_PROMPTS.title_generation },
{ role: 'user', content: userMessage },

View File

@ -12,6 +12,30 @@ export function toTitleCase(str: string): string {
.join(' ')
}
/**
* Decide which model runs an ancillary AI task (chat titles, chat suggestions).
*
* `configured` is the user's `ai.tasksModel` setting. It only wins when the
* model is still installed a model can be deleted from /settings/models long
* after it was picked here, and a request for a missing model 404s. In every
* other case the caller's existing `fallback` applies, so an unset setting
* leaves prior behaviour untouched.
*/
export function pickTasksModel(
configured: string | null | undefined,
installedNames: string[],
fallback: string | null
): { model: string | null; staleConfigured: string | null } {
const trimmed = configured?.trim()
if (!trimmed) {
return { model: fallback, staleConfigured: null }
}
if (installedNames.includes(trimmed)) {
return { model: trimmed, staleConfigured: null }
}
return { model: fallback, staleConfigured: trimmed }
}
export function parseBoolean(value: any): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {

View File

@ -11,6 +11,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [
'ai.remoteOllamaUrl',
'ai.ollamaFlashAttention',
'ai.autoThinking',
'ai.tasksModel',
'rag.defaultIngestPolicy',
'autoUpdate.enabled',
'autoUpdate.windowStart',

View File

@ -13,6 +13,7 @@ import StyledModal from '~/components/StyledModal'
import type { NomadInstalledModel } from '../../../types/ollama'
import { SERVICE_NAMES } from '../../../constants/service_names'
import Switch from '~/components/inputs/Switch'
import Select from '~/components/inputs/Select'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import { useMutation, useQuery } from '@tanstack/react-query'
import Input from '~/components/inputs/Input'
@ -26,7 +27,7 @@ export default function ModelsPage(props: {
models: {
availableModels: NomadOllamaModel[]
installedModels: NomadInstalledModel[]
settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string; remoteOllamaUrl: string; ollamaFlashAttention: boolean; autoThinking: boolean }
settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string; remoteOllamaUrl: string; ollamaFlashAttention: boolean; autoThinking: boolean; tasksModel: string }
}
}) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
@ -99,6 +100,7 @@ export default function ModelsPage(props: {
props.models.settings.ollamaFlashAttention
)
const [autoThinking, setAutoThinking] = useState(props.models.settings.autoThinking)
const [tasksModel, setTasksModel] = useState(props.models.settings.tasksModel)
const [aiAssistantCustomName, setAiAssistantCustomName] = useState(
props.models.settings.aiAssistantCustomName
)
@ -241,6 +243,17 @@ export default function ModelsPage(props: {
)
}
// A model can be deleted after being picked here. Surface the stale name as a
// disabled option instead of letting the select silently render empty — the
// backend already falls back to the chat model at call time.
const tasksModelOptions = [
{ value: '', label: 'Use the chat model' },
...props.models.installedModels.map((model) => ({ value: model.name, label: model.name })),
...(tasksModel && !props.models.installedModels.some((m) => m.name === tasksModel)
? [{ value: tasksModel, label: `${tasksModel} (not installed)`, disabled: true }]
: []),
]
const updateSettingMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: boolean | string }) => {
return await api.updateSetting(key, value)
@ -344,6 +357,17 @@ export default function ModelsPage(props: {
})
}
/>
<Select
name="tasksModel"
label="Tasks Model"
helpText="Small, fast model used for background work like chat titles and suggestions. Leave this set to the chat model to use whichever model the chat is using. Avoid reasoning models here — they are slow at short, aesthetic tasks."
value={tasksModel}
options={tasksModelOptions}
onChange={(newVal) => {
setTasksModel(newVal)
updateSettingMutation.mutate({ key: 'ai.tasksModel', value: newVal })
}}
/>
</div>
</div>

View File

@ -0,0 +1,77 @@
/**
* Tests for the tasks-model decision which model runs ancillary AI work
* (chat titles, chat suggestions) rather than the chat model the user picked.
*
* The decision is a pure function so the "configured model was deleted" path is
* testable without an Ollama server; ChatService.resolveTasksModel does only the
* KV read and the model listing around it.
*
* Pure functions only no MySQL, Redis, Qdrant, or Ollama needed:
* npm run test:unit
*/
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { pickTasksModel } from '../../app/utils/misc.js'
const INSTALLED = ['llama3.1:8b', 'qwen2.5:3b', 'gpt-oss:20b']
test('tasks model: unset setting falls back to the caller default', () => {
assert.deepEqual(pickTasksModel(null, INSTALLED, 'llama3.1:8b'), {
model: 'llama3.1:8b',
staleConfigured: null,
})
assert.deepEqual(pickTasksModel(undefined, INSTALLED, 'llama3.1:8b'), {
model: 'llama3.1:8b',
staleConfigured: null,
})
})
test('tasks model: empty and whitespace-only settings count as unset', () => {
// SystemService.updateSetting clears the row on an empty string, but a value
// written before that behaviour (or by hand) must not select a "" model.
assert.equal(pickTasksModel('', INSTALLED, 'llama3.1:8b').model, 'llama3.1:8b')
assert.equal(pickTasksModel(' ', INSTALLED, 'llama3.1:8b').model, 'llama3.1:8b')
})
test('tasks model: a configured, installed model wins over the chat model', () => {
assert.deepEqual(pickTasksModel('qwen2.5:3b', INSTALLED, 'gpt-oss:20b'), {
model: 'qwen2.5:3b',
staleConfigured: null,
})
})
test('tasks model: surrounding whitespace is trimmed before matching', () => {
assert.equal(pickTasksModel(' qwen2.5:3b ', INSTALLED, 'gpt-oss:20b').model, 'qwen2.5:3b')
})
test('tasks model: an uninstalled configured model falls back and reports itself stale', () => {
// The user deleted the model from /settings/models after selecting it here.
// Requesting it would 404, so the caller's fallback runs and the name is
// handed back for the warning log.
assert.deepEqual(pickTasksModel('llama3.2:1b', INSTALLED, 'gpt-oss:20b'), {
model: 'gpt-oss:20b',
staleConfigured: 'llama3.2:1b',
})
})
test('tasks model: model names match exactly, not by prefix', () => {
// "llama3.1" is a family, not an installed tag; only "llama3.1:8b" is pullable.
assert.equal(pickTasksModel('llama3.1', INSTALLED, 'gpt-oss:20b').model, 'gpt-oss:20b')
})
test('tasks model: nothing installed falls back', () => {
assert.deepEqual(pickTasksModel('qwen2.5:3b', [], 'gpt-oss:20b'), {
model: 'gpt-oss:20b',
staleConfigured: 'qwen2.5:3b',
})
})
test('tasks model: a null fallback stays null', () => {
// getChatSuggestions has no model to fall back to when nothing is installed.
assert.deepEqual(pickTasksModel(null, [], null), { model: null, staleConfigured: null })
assert.deepEqual(pickTasksModel('qwen2.5:3b', [], null), {
model: null,
staleConfigured: 'qwen2.5:3b',
})
})

View File

@ -39,6 +39,10 @@ export const KV_STORE_SCHEMA = {
'ai.remoteOllamaUrl': 'string',
'ai.ollamaFlashAttention': 'boolean',
'ai.autoThinking': 'boolean',
// Model used for ancillary AI work (chat titles, chat suggestions) instead of
// whatever chat model the user last used. Unset/null keeps the previous
// behaviour: titles use the chat model, suggestions use chat.lastModel.
'ai.tasksModel': 'string',
'ai.amdGpuAcceleration': 'boolean',
'ai.amdHsaOverride': 'string',
'ai.autoFixGpuPassthrough': 'boolean',