fix(ui): re-check add-ons before showing the off notice on Home

The notice came from navigation state, which survives a refresh, so it
came back after add-ons were on again. A frontend also reports add-ons
as off when the owner does not answer for a moment, which looked the
same as the instance switch being off.

Home now reads /api/features on mount, drops the notice and clears the
history state when add-ons are on, and clears the state on dismiss.
/api/features gains addons_enabled (the instance switch alone), so the
notice can say "not answering right now" with a retry button when the
switch is on and the owner is silent.

Assisted-by: Claude:claude-sonnet-5-5 eslint node go-test
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 17:19:38 +00:00
1 parent f60e464fb3
commit c007ccf994
6 files changed
+71 -5

No files matched your search

@@ -280,6 +280,9 @@
"addonsOffTitle": "Add-ons are off on this server",
"addonsOffAdmin": "{{name}} is an add-on. Start LocalAI with add-ons on, then install it from Add-ons.",
"addonsOffReader": "{{name}} is an add-on. Ask an administrator to turn add-ons on.",
"addonsDownTitle": "Add-ons are not answering right now",
"addonsDownBody": "{{name}} is an add-on. Try again in a moment.",
"addonsRetry": "Try again",
"thisPage": "That page",
"addonsDocs": "How to turn add-ons on",
"dismiss": "Dismiss"
+29 -5
View File
@@ -38,6 +38,7 @@ import { useAddonHomeCommands } from '../addons/useAddonRegistry'
import { projectHomeCommands } from '../addons/homeCommands'
import { reservedNames, usableSkills } from '../utils/slashCommands'
import { backendControlApi, chatsApi, modelsApi, systemApi, nodesApi } from '../utils/api'
import { addonsNotice } from '../utils/addonsNotice'
import { greetingKey } from '../utils/greeting'
import { resumeItems } from '../utils/homeConversations'
// eslint-disable-next-line no-unused-vars
@@ -114,7 +115,26 @@ export default function Home() {
// An add-on address opened while add-ons are off on the server lands here
// (see AddonsShell). The notice
// says why, and how to get the add-on.
const [addonNotice, setAddonNotice] = useState(() => location.state?.addonsOff || '')
// The state outlives the cause: a refresh keeps it after add-ons are back, so
// it is checked against /api/features before it is shown.
const [addonNotice, setAddonNotice] = useState('')
const [addonNoticeKind, setAddonNoticeKind] = useState('off')
const stateAddon = location.state?.addonsOff || ''
const pathname = location.pathname
const clearAddonState = useCallback(() => {
if (stateAddon) navigate(pathname, { replace: true, state: null })
}, [navigate, pathname, stateAddon])
const checkAddons = useCallback(async () => {
if (!stateAddon) return
let data = null
try { data = await (await fetch(apiUrl('/api/features'))).json() } catch { /* keep the notice */ }
const notice = addonsNotice(stateAddon, data)
setAddonNotice(notice.addon)
setAddonNoticeKind(notice.kind)
if (!notice.addon) clearAddonState()
}, [stateAddon, clearAddonState])
useEffect(() => { checkAddons() }, [checkAddons])
const dismissAddonNotice = () => { setAddonNotice(''); clearAddonState() }
// Detect distributed mode.
useEffect(() => {
@@ -561,15 +581,19 @@ export default function Home() {
{addonNotice && (
<Callout
tone="info" icon="puzzle" role="note" testId="home-addons-off"
title={t('notice.addonsOffTitle')}
title={t(addonNoticeKind === 'unreachable' ? 'notice.addonsDownTitle' : 'notice.addonsOffTitle')}
actions={(
<>
<Button size="sm" href={ADDONS_DOCS_URL} target="_blank" rel="noopener noreferrer">{t('notice.addonsDocs')}</Button>
<Button size="sm" variant="ghost" onClick={() => setAddonNotice('')}>{t('notice.dismiss')}</Button>
{addonNoticeKind === 'unreachable'
? <Button size="sm" onClick={checkAddons}>{t('notice.addonsRetry')}</Button>
: <Button size="sm" href={ADDONS_DOCS_URL} target="_blank" rel="noopener noreferrer">{t('notice.addonsDocs')}</Button>}
<Button size="sm" variant="ghost" onClick={dismissAddonNotice}>{t('notice.dismiss')}</Button>
</>
)}
>
{t(isAdmin ? 'notice.addonsOffAdmin' : 'notice.addonsOffReader', { name: t('notice.thisPage') })}
{addonNoticeKind === 'unreachable'
? t('notice.addonsDownBody', { name: t('notice.thisPage') })
: t(isAdmin ? 'notice.addonsOffAdmin' : 'notice.addonsOffReader', { name: t('notice.thisPage') })}
</Callout>
)}
+15
View File
@@ -0,0 +1,15 @@
// Decides the Home notice for an add-on address that was opened while add-ons
// were off. `addon` is the name kept in the navigation state; `features` is the
// body of /api/features, or null when it could not be read.
//
// The navigation state survives a refresh, so it only says that add-ons were
// off once. The features say whether they still are.
// kind 'off' the instance switch is off
// kind 'unreachable' the switch is on and the owner does not answer
// With no features answer the notice stays as it was (kind 'off').
export function addonsNotice(addon, features) {
if (!addon) return { addon: '', kind: 'off' }
if (!features) return { addon, kind: 'off' }
if (features.addons !== false) return { addon: '', kind: 'off' }
return { addon, kind: features.addons_enabled === true ? 'unreachable' : 'off' }
}
@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { addonsNotice } from '../../src/utils/addonsNotice.js'
test('Home add-ons notice: add-ons back on drops the notice', () => {
assert.deepEqual(addonsNotice('studio', { addons: true, addons_enabled: true }), { addon: '', kind: 'off' })
assert.equal(addonsNotice('studio', {}).addon, '')
})
test('Home add-ons notice: switch off keeps the notice', () => {
assert.deepEqual(addonsNotice('studio', { addons: false, addons_enabled: false }), { addon: 'studio', kind: 'off' })
assert.deepEqual(addonsNotice('studio', { addons: false }), { addon: 'studio', kind: 'off' })
})
test('Home add-ons notice: switch on and owner silent says not answering', () => {
assert.deepEqual(addonsNotice('studio', { addons: false, addons_enabled: true }), { addon: 'studio', kind: 'unreachable' })
})
test('Home add-ons notice: no state shows nothing, unreadable features keep it', () => {
assert.equal(addonsNotice('', { addons: false }).addon, '')
assert.deepEqual(addonsNotice('studio', null), { addon: 'studio', kind: 'off' })
})
+3
View File
@@ -14,6 +14,8 @@ import (
func RegisterFeatureRoutes(router *echo.Echo, appConfig *config.ApplicationConfig, app *application.Application) {
router.GET("/api/features", func(c echo.Context) error {
addons := app.AddonsFeature(c.Request().Context())
// addons_enabled is the instance switch alone. With addons false, true
// there means the owner does not answer right now, not that it is off.
// model_exports is false only when this instance does not see the
// owner's models path; an unknown check leaves it true.
// The chats keys say what runs on this server, not what a user may use:
@@ -29,6 +31,7 @@ func RegisterFeatureRoutes(router *echo.Echo, appConfig *config.ApplicationConfi
"collections": appConfig.Collections.Enabled,
"mcp": !appConfig.DisableMCP,
"addons": addons,
"addons_enabled": app.AddonsEnabled(),
"fine_tuning": addons,
"quantization": addons,
"model_exports": addons && app.ModelExportsAllowed(c.Request().Context()),
+2
View File
@@ -51,6 +51,7 @@ var _ = Describe("GET /api/features and the addons gate", func() {
It("reports addons, fine_tuning and quantization as false with add-ons off", func() {
got := features()
Expect(got).To(HaveKeyWithValue("addons", false))
Expect(got).To(HaveKeyWithValue("addons_enabled", false))
Expect(got).To(HaveKeyWithValue("fine_tuning", false))
Expect(got).To(HaveKeyWithValue("quantization", false))
})
@@ -76,6 +77,7 @@ var _ = Describe("GET /api/features and the addons gate", func() {
Host: contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"},
}))
Expect(got).To(HaveKeyWithValue("addons", true))
Expect(got).To(HaveKeyWithValue("addons_enabled", true))
Expect(got).To(HaveKeyWithValue("fine_tuning", true))
Expect(got).To(HaveKeyWithValue("quantization", true))
})