project-nomad/admin/app/controllers/settings_controller.ts
jakeaturner 27be71b46e feat(AI): give chat a sampler instead of the backend's defaults
Chat sent num_ctx, num_predict and nothing else, so every turn decoded at
whatever the backend picked: temperature 0.8, top_p 0.9, no min_p. On a
heavily quantized 1-3B model that is the wandering, repetitive output people
read as "the local model is bad".

Adds `ai.responseStyle` with four profiles. min_p is the lever, not
temperature: it floors relative to the top token, so it truncates hard on a
confident distribution and barely at all on a flat one, which is where small
models start to wander. top_p stays at 1.0 rather than stacked on top of it.
repeat_penalty moves with the style, because a grounded answer legitimately
repeats a part number inside the 64-token window: `focused` relaxes it to
1.05, `creative` tightens it to 1.15.

`auto` is the only style that defers to the model. Plenty of library models
ship author-tuned samplers (qwen3 sets 0.6/0.95/20), so those win key by key
and NOMAD fills only the gap, which in practice is min_p. A style the user
picked by name overrides them, or it would silently do nothing on half
their models.

The compat transport drops min_p, so a profile carries a separate compatTopP.
Passing top_p 1.0 to a backend with no min_p behind it would sample the raw
distribution, which is worse than the 0.9 it replaced.

Resolved in the controller, so the grammar-constrained task calls keep their
temperature 0. An explicit temperature still outranks a profile's, which is
what keeps the eval harness deterministic.

`eval:generation --style` is new and load-bearing: temperature 0 makes min_p,
top_p and top_k inert, so a default run cannot see this change at all.
`--style=off` sends no samplers and no temperature, and is the control.

Verified against Ollama 0.32.9, min_p accepted. 22 unit tests.
2026-09-29 15:54:41 -07:00

186 lines
6.9 KiB
TypeScript

import KVStore from '#models/kv_store'
import { BenchmarkService } from '#services/benchmark_service'
import { ContextWindowService } from '#services/context_window_service'
import { MapService } from '#services/map_service'
import { OllamaService } from '#services/ollama_service'
import { SystemService } from '#services/system_service'
import { getSettingSchema, updateSettingSchema, validateSettingValue } from '#validators/settings'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import env from '#start/env'
import { parseMinRelevance } from '../utils/misc.js'
import { parseResponseStyle } from '../utils/sampler.js'
import { RAG_MIN_FINAL_SCORE } from '../../constants/ollama.js'
@inject()
export default class SettingsController {
constructor(
private systemService: SystemService,
private mapService: MapService,
private benchmarkService: BenchmarkService,
private ollamaService: OllamaService,
private contextWindowService: ContextWindowService
) {}
async system({ inertia }: HttpContext) {
const systemInfo = await this.systemService.getSystemInfo()
return inertia.render('settings/system', {
system: {
info: systemInfo,
},
})
}
async apps({ inertia }: HttpContext) {
const services = await this.systemService.getServices({ installedOnly: false })
return inertia.render('settings/apps', {
system: {
services,
},
})
}
async legal({ inertia }: HttpContext) {
return inertia.render('settings/legal')
}
async support({ inertia }: HttpContext) {
return inertia.render('settings/support')
}
async maps({ inertia }: HttpContext) {
const baseAssetsCheck = await this.mapService.ensureBaseAssets()
const [regionFiles, worldBasemapExists] = await Promise.all([
this.mapService.listRegions(),
this.mapService.checkWorldBasemapExists(),
])
return inertia.render('settings/maps', {
maps: {
baseAssetsExist: baseAssetsCheck,
worldBasemapExists,
regionFiles: regionFiles.files,
},
})
}
async models({ inertia }: HttpContext) {
const availableModels = await this.ollamaService.getAvailableModels({
sort: 'pulls',
recommendedOnly: false,
query: null,
limit: 15,
})
const installedModels = await this.ollamaService.getModels().catch(() => [])
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
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')
const tasksModel = await KVStore.getValue('ai.tasksModel')
const ragEnabled = await KVStore.getValue('rag.enabled')
const contextWindow = await KVStore.getValue('ai.contextWindow')
const minRelevance = await KVStore.getValue('rag.minRelevance')
const responseStyle = await KVStore.getValue('ai.responseStyle')
// Resolved window per installed model, so the setting shows what "Auto"
// actually produced rather than leaving the user to guess. Best-effort:
// a model whose metadata can't be read simply doesn't get a badge.
const resolvedContextWindows: Record<string, number> = {}
await Promise.all(
(installedModels || []).map(async (model) => {
try {
resolvedContextWindows[model.name] = await this.contextWindowService.windowFor(model.name)
} catch {
/* leave unset */
}
})
)
return inertia.render('settings/models', {
models: {
availableModels: availableModels?.models || [],
installedModels: installedModels || [],
settings: {
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
aiAssistantCustomName: aiAssistantCustomName ?? '',
remoteOllamaUrl: remoteOllamaUrl ?? '',
ollamaFlashAttention: ollamaFlashAttention ?? true,
autoThinking: autoThinking ?? false,
tasksModel: tasksModel ?? '',
ragEnabled: ragEnabled ?? true,
contextWindow: contextWindow ?? 'auto',
// Sent as the resolved number so the select can match an option
// without duplicating the "unset means the default" rule in the UI.
minRelevance: parseMinRelevance(minRelevance, RAG_MIN_FINAL_SCORE),
// Resolved rather than raw, for the same reason: unset means 'auto',
// and the select shouldn't have to know that.
responseStyle: parseResponseStyle(responseStyle),
},
resolvedContextWindows,
},
})
}
async update({ inertia }: HttpContext) {
const updateInfo = await this.systemService.checkLatestVersion()
return inertia.render('settings/update', {
system: {
updateAvailable: updateInfo.updateAvailable,
latestVersion: updateInfo.latestVersion,
currentVersion: updateInfo.currentVersion,
},
})
}
async zim({ inertia }: HttpContext) {
return inertia.render('settings/zim/index')
}
async zimRemote({ inertia }: HttpContext) {
return inertia.render('settings/zim/remote-explorer')
}
async creatorPacks({ inertia }: HttpContext) {
return inertia.render('settings/creator-packs')
}
async benchmark({ inertia }: HttpContext) {
const latestResult = await this.benchmarkService.getLatestResult()
const status = this.benchmarkService.getStatus()
return inertia.render('settings/benchmark', {
benchmark: {
latestResult,
status: status.status,
currentBenchmarkId: status.benchmarkId,
},
})
}
async advanced({ inertia }: HttpContext) {
// When the env var is set it always takes precedence over the stored value,
// so surface that to the UI to disable the field and explain the override.
const envOverride = Boolean(env.get('INTERNET_STATUS_TEST_URL')?.trim())
const internetStatusTestUrl = await KVStore.getValue('system.internetStatusTestUrl')
return inertia.render('settings/advanced', {
advanced: {
internetStatusTestUrl: internetStatusTestUrl ?? '',
internetStatusTestUrlEnvOverride: envOverride,
},
})
}
async getSetting({ request, response }: HttpContext) {
const { key } = await getSettingSchema.validate({ key: request.qs().key });
const value = await KVStore.getValue(key);
return response.status(200).send({ key, value });
}
async updateSetting({ request, response }: HttpContext) {
const reqData = await request.validateUsing(updateSettingSchema)
const valueError = validateSettingValue(reqData.key, reqData.value)
if (valueError) {
return response.status(422).send({ success: false, message: valueError })
}
await this.systemService.updateSetting(reqData.key, reqData.value)
return response.status(200).send({ success: true, message: 'Setting updated successfully' })
}
}