mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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:
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"
|
||||
|
||||
@@ -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
@@ -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' })
|
||||
})
|
||||
@@ -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()),
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user