From c007ccf9947b8a01982b1171c03ed2df9bb4be71 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 17:19:38 +0000 Subject: [PATCH] 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 --- .../http/react-ui/public/locales/en/home.json | 3 ++ core/http/react-ui/src/pages/Home.jsx | 34 ++++++++++++++++--- core/http/react-ui/src/utils/addonsNotice.js | 15 ++++++++ .../react-ui/tests/unit/addonsnotice.test.mjs | 19 +++++++++++ core/http/routes/features.go | 3 ++ core/http/routes/features_test.go | 2 ++ 6 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 core/http/react-ui/src/utils/addonsNotice.js create mode 100644 core/http/react-ui/tests/unit/addonsnotice.test.mjs diff --git a/core/http/react-ui/public/locales/en/home.json b/core/http/react-ui/public/locales/en/home.json index de2f4803be..1609b145b4 100644 --- a/core/http/react-ui/public/locales/en/home.json +++ b/core/http/react-ui/public/locales/en/home.json @@ -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" diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx index 55159f21ff..0d47a72a52 100644 --- a/core/http/react-ui/src/pages/Home.jsx +++ b/core/http/react-ui/src/pages/Home.jsx @@ -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 && ( - - + {addonNoticeKind === 'unreachable' + ? + : } + )} > - {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') })} )} diff --git a/core/http/react-ui/src/utils/addonsNotice.js b/core/http/react-ui/src/utils/addonsNotice.js new file mode 100644 index 0000000000..00e9f5f7f4 --- /dev/null +++ b/core/http/react-ui/src/utils/addonsNotice.js @@ -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' } +} diff --git a/core/http/react-ui/tests/unit/addonsnotice.test.mjs b/core/http/react-ui/tests/unit/addonsnotice.test.mjs new file mode 100644 index 0000000000..b779ca8f84 --- /dev/null +++ b/core/http/react-ui/tests/unit/addonsnotice.test.mjs @@ -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' }) +}) diff --git a/core/http/routes/features.go b/core/http/routes/features.go index 35b154aa0f..afaf69d1c7 100644 --- a/core/http/routes/features.go +++ b/core/http/routes/features.go @@ -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()), diff --git a/core/http/routes/features_test.go b/core/http/routes/features_test.go index 661328f663..bf0db34d42 100644 --- a/core/http/routes/features_test.go +++ b/core/http/routes/features_test.go @@ -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)) })