From 9012d3220c7491cc74a238eb2fd5133a9f3be1a4 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 5 Nov 2026 00:42:58 +0000 Subject: [PATCH] feat(addons): say on the Memory add-on page that memory is built in Add a Notice component to the add-on settings kit so a package can show the host's own notice: severity, title, reason and one action. The Memory add-on page uses it to say that memory is built in and to link to Settings > Memory. A host without Notice falls back to Callout. Co-Authored-By: Claude Sonnet 5.5 --- addons/internal/uibuild/kitstub.mjs | 3 +- addons/memory/ui/src/index.jsx | 16 +++++++++ addons/memory/ui/test/settings.test.mjs | 10 ++++++ .../react-ui/e2e/memory-addon-notice.spec.js | 36 +++++++++++++++++++ .../react-ui/src/addons/hostComponents.jsx | 8 +++++ core/http/react-ui/src/addons/hostServices.js | 4 +-- docs/content/features/addons.md | 1 + 7 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 core/http/react-ui/e2e/memory-addon-notice.spec.js diff --git a/addons/internal/uibuild/kitstub.mjs b/addons/internal/uibuild/kitstub.mjs index c41fd8101f..795e60a635 100644 --- a/addons/internal/uibuild/kitstub.mjs +++ b/addons/internal/uibuild/kitstub.mjs @@ -3,7 +3,7 @@ // (core/http/react-ui/src/addons/hostComponents.jsx); these keep the same // props and draw plain markup with the test ids, so a test can check what an // add-on asks the host to draw without the host's build. -export const KIT = ['Section', 'Field', 'Select', 'TextInput', 'Switch', 'Choice', 'Callout', 'StatusChip', 'List', 'ListRow', 'Code', 'Actions', 'Table', 'ExternalLink'] +export const KIT = ['Section', 'Field', 'Select', 'TextInput', 'Switch', 'Choice', 'Callout', 'Notice', 'StatusChip', 'List', 'ListRow', 'Code', 'Actions', 'Table', 'ExternalLink'] export function kitStubs(React) { const h = React.createElement @@ -18,6 +18,7 @@ export function kitStubs(React) { (p.options || []).map(o => h('label', { key: o.value, 'data-value': o.value, 'data-selected': p.value === o.value ? '' : undefined }, h('input', { type: 'radio', checked: p.value === o.value, onChange: () => {}, disabled: o.disabled }), o.label, o.chip || null, o.description ? h('span', null, o.description) : null))), Callout: (p) => h('div', { 'data-kit': 'Callout', 'data-tone': p.tone || 'info', role: p.role, ...tid(p) }, p.title ? h('strong', null, p.title) : null, p.children, p.actions || null), + Notice: (p) => h('div', { 'data-kit': 'Notice', 'data-severity': p.severity || 'info', role: 'note', ...tid(p) }, p.title ? h('strong', null, p.title) : null, p.reason ? h('p', null, p.reason) : null, p.fixLabel ? h('button', { type: 'button', onClick: p.onFix }, p.fixLabel) : null, p.children), StatusChip: (p) => h('span', { 'data-kit': 'StatusChip', 'data-tone': p.tone || 'idle', ...tid(p) }, p.children), List: (p) => h('ul', { 'data-kit': 'List', 'aria-label': p.label, ...tid(p) }, p.children), ListRow: (p) => h('li', { 'data-kit': 'ListRow', ...tid(p) }, h('strong', null, p.title), p.status || null, p.meta ? h('span', null, p.meta) : null, p.children, diff --git a/addons/memory/ui/src/index.jsx b/addons/memory/ui/src/index.jsx index 439c41644c..a3a9ea79b6 100644 --- a/addons/memory/ui/src/index.jsx +++ b/addons/memory/ui/src/index.jsx @@ -9,6 +9,8 @@ // validates it. The rules that make it safe are the host's: this page only // states them. export const NOTES_ADDRESS = '/app/account?tab=memory' +// Memory is built in; the save level lives in Settings > Memory. +export const SETTINGS_ADDRESS = '/app/account?tab=memory' export const SAVE_MODE_KEY = 'save-mode' export const SAVE_MODES = ['ask', 'auto', 'never'] @@ -23,6 +25,9 @@ export function createAddon(sdk) { 'memory.rules.scopes': 'There are three kinds of notes: yours for every chat, one for each saved agent, and one for each conversation that comes from a messaging app. A saved agent reads notes only when you allow this add-on for it.', 'memory.rules.limits': 'The model reads a short list, at most 40 lines for each kind, and opens a topic when it needs the details.', 'memory.open': 'Open my notes', + 'memory.builtin.title': 'Memory is built in', + 'memory.builtin.reason': 'Memory is part of LocalAI now, and you do not need this add-on for it. Choose how notes are saved in Settings > Memory.', + 'memory.builtin.fix': 'Open Settings > Memory', 'memory.save.title': 'Saving a note', 'memory.save.legend': 'When the assistant wants to save something to my memory', 'memory.save.ask': 'Ask me first', @@ -86,11 +91,22 @@ export function createAddon(sdk) { failed ? h(sdk.ui.Callout, { tone: 'warning', role: 'alert', testId: 'memory-save-failed' }, t('memory.save.failed')) : null) } + // Memory is part of the host now. Older hosts have no Notice; they get the + // Callout with the same words. + function BuiltinNotice() { + const open = () => sdk.navigation.openApp(SETTINGS_ADDRESS) + if (sdk.ui.Notice) { + return h(sdk.ui.Notice, { severity: 'info', title: t('memory.builtin.title'), reason: t('memory.builtin.reason'), fixLabel: t('memory.builtin.fix'), onFix: open, testId: 'memory-builtin-notice' }) + } + return h(sdk.ui.Callout, { tone: 'info', title: t('memory.builtin.title'), testId: 'memory-builtin-notice' }, t('memory.builtin.reason')) + } + function Component({ page }) { if (page !== 'memory') return null return (

{t('memory.title')}

+

{t('memory.intro')}

  • {t('memory.rules.confirm')}
  • diff --git a/addons/memory/ui/test/settings.test.mjs b/addons/memory/ui/test/settings.test.mjs index f46d21bdde..477345d2d8 100644 --- a/addons/memory/ui/test/settings.test.mjs +++ b/addons/memory/ui/test/settings.test.mjs @@ -124,3 +124,13 @@ test('settings: the page reads the setting and does not guess before it arrives' assert.match(html, /data-testid="memory-save-loading"/) assert.doesNotMatch(html, /type="radio"/) }) + +test('settings: the page says memory is built in and links to Settings > Memory', async () => { + const p = await mount(async () => view('ask')) + await settle() + const n = p.q('[data-testid="memory-builtin-notice"]') + assert.ok(n, 'the notice is on the page') + assert.match(n.textContent, /Memory is built in/) + assert.match(n.textContent, /Settings > Memory/) + await p.close() +}) diff --git a/core/http/react-ui/e2e/memory-addon-notice.spec.js b/core/http/react-ui/e2e/memory-addon-notice.spec.js new file mode 100644 index 0000000000..e99a3023ab --- /dev/null +++ b/core/http/react-ui/e2e/memory-addon-notice.spec.js @@ -0,0 +1,36 @@ +import fs from 'node:fs' +import path from 'node:path' +import { test, expect } from './coverage-fixtures.js' +import { ADMIN, mockAddons, sessionFor, stubAddonRoutes } from './addons-fixtures.js' +import { buildAddonBundles, packagedApp } from './studio-addon.js' + +// The page of the Memory add-on says that memory is built in and links to +// Settings > Memory. The add-on is served from its build output. + +const DIST = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../../addons/memory/ui/dist') + +test.beforeAll(async () => { + if (process.env.E2E_ADDON_BUNDLES_BUILT) return + await buildAddonBundles(['memory']) +}) + +test('the add-on page has a notice that memory is built in, with a link to Settings > Memory', async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) })) + await stubAddonRoutes(page) + await page.route(/\/api\/addons\/memory\/assets\//, (route) => { + const file = new URL(route.request().url()).pathname.split('/').slice(6).join('/') + const type = { js: 'text/javascript', css: 'text/css', png: 'image/png' }[file.split('.').pop()] + if (!type || !fs.existsSync(path.join(DIST, file))) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } }) + return route.fulfill({ status: 200, contentType: type, headers: { 'cache-control': 'no-store' }, body: fs.readFileSync(path.join(DIST, file)) }) + }) + await page.route('**/api/addons/memory/user-settings', route => route.fulfill({ json: { settings: [{ key: 'save-mode', values: ['ask', 'auto', 'never'], default: 'ask', value: 'ask' }] } })) + await mockAddons(page, { apps: [packagedApp('memory')] }) + await page.goto('/app/addons/memory/memory') + const notice = page.getByTestId('memory-builtin-notice') + await expect(notice).toBeVisible() + await expect(notice).toContainText('Memory is built in') + await expect(notice).toContainText('Settings > Memory') + await page.screenshot({ path: 'test-results/memory-addon-notice.png' }) + await notice.getByRole('button', { name: 'Open Settings > Memory' }).click() + await expect(page).toHaveURL(/\/app\/account\?tab=memory$/) +}) diff --git a/core/http/react-ui/src/addons/hostComponents.jsx b/core/http/react-ui/src/addons/hostComponents.jsx index d6d2cb113d..e66a474994 100644 --- a/core/http/react-ui/src/addons/hostComponents.jsx +++ b/core/http/react-ui/src/addons/hostComponents.jsx @@ -6,6 +6,7 @@ import StatusPill from '../components/StatusPill' import { useId } from 'react' import HostSwitch from '../components/ui/Switch' import HostCallout from '../components/ui/Callout' +import HostNotice from '../components/ui/Notice' import HostStatusChip from '../components/ui/StatusChip' // Small wrappers over components the host already ships: an add-on gets the @@ -166,6 +167,13 @@ export function Callout({ tone = 'info', title, actions, role, testId, children return {children} } +// The host's Notice: what is off or not available, why, and one fix. The +// add-on gives plain data; the fix is a label and a handler, never markup. +export function Notice({ severity = 'info', title, reason, fixLabel, onFix, testId, children }) { + const fix = fixLabel && typeof onFix === 'function' ? { label: String(fixLabel), onClick: onFix } : undefined + return {children} +} + export function StatusChip({ tone = 'idle', children, testId, title }) { return {children} } diff --git a/core/http/react-ui/src/addons/hostServices.js b/core/http/react-ui/src/addons/hostServices.js index f10f92ed5e..f30da80763 100644 --- a/core/http/react-ui/src/addons/hostServices.js +++ b/core/http/react-ui/src/addons/hostServices.js @@ -7,7 +7,7 @@ import { resourceRequest, dispatchArgs } from './resourceCalls' import { userSettingsRequest } from './userSettingsCalls' import { Button, Spinner, EmptyState, Badge, Stack, Text, - Section, Field, Select, TextInput, Switch, Choice, Callout, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink, + Section, Field, Select, TextInput, Switch, Choice, Callout, Notice, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink, PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer, DistributionBars, EmbeddingInspector, TabSwitch, } from './hostComponents' @@ -74,7 +74,7 @@ export const host = Object.freeze({ React, components: { Button, Spinner, EmptyState, Badge, Stack, Text, ArtifactCard, - Section, Field, Select, TextInput, Switch, Choice, Callout, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink, + Section, Field, Select, TextInput, Switch, Choice, Callout, Notice, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink, PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer, DistributionBars, EmbeddingInspector, TabSwitch, LoadingSpinner, Modal, Lightbox, ModelPicker, HomeModelPicker: ModelPicker, diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 0638ef0230..1dc878fb6a 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -1926,6 +1926,7 @@ your own: a class the host does not define has no style. | `Code` | `testId` | A monospace block for a raw error or a command, wrapped and scrolled. | | `Actions` | none | A row of buttons that wraps. | | `Table` | `caption`, `columns: [{key, label}]`, `rows`, `testId` | The host table. | +| `Notice` | `severity` (`info`, `warn`, `error`), `title`, `reason`, `fixLabel` with `onFix`, `testId` | What is off or not available, why, and one action. Hosts older than this release do not have it: fall back to `Callout`. | | `ExternalLink` | `href` (https only), `testId` | A link out, in a new tab. | The Sandbox and Web sections are made of these components only (`addons/sandbox/ui/src/Settings.jsx`,