From 69bbd61b44d0dcc889bbf81f71ac51260bec4119 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 5 Nov 2026 00:42:58 +0000 Subject: [PATCH] feat(ui): name the knowledge command and explain the Conversations notes The Home slash command that opens collections is now /knowledge. /memory still finds it, and no skill can take either name. The Conversations tab of Settings > Memory gets a line that says what the notes are and that a sender who is not paired always waits for approval. Add an end-to-end test that reads and deletes one of them. Co-Authored-By: Claude Sonnet 5.5 --- core/http/react-ui/e2e/home-console.spec.js | 13 +++++++++++- .../http/react-ui/e2e/settings-memory.spec.js | 21 +++++++++++++++++++ .../react-ui/public/locales/de/chats.json | 1 + .../react-ui/public/locales/en/chats.json | 1 + .../react-ui/public/locales/es/chats.json | 1 + .../react-ui/public/locales/fr/chats.json | 1 + .../react-ui/public/locales/id/chats.json | 1 + .../react-ui/public/locales/it/chats.json | 1 + .../react-ui/public/locales/ko/chats.json | 1 + .../react-ui/public/locales/pt-BR/chats.json | 1 + .../react-ui/public/locales/zh-CN/chats.json | 1 + .../react-ui/src/components/MemoryTab.jsx | 1 + .../src/components/home/HomeComposer.jsx | 4 ++-- .../src/components/home/homeActions.js | 9 ++++++-- core/http/react-ui/src/utils/slashCommands.js | 6 +++--- 15 files changed, 55 insertions(+), 8 deletions(-) diff --git a/core/http/react-ui/e2e/home-console.spec.js b/core/http/react-ui/e2e/home-console.spec.js index 0b8ae5fab5..6bf8191464 100644 --- a/core/http/react-ui/e2e/home-console.spec.js +++ b/core/http/react-ui/e2e/home-console.spec.js @@ -40,7 +40,7 @@ test.describe('Home slash menu', () => { '/model', '/new', '/chat', '/assistant', '/models', '/gallery', '/installed', '/import', '/stop', // No installation advice for a particular provider. - '/agents', '/mcp', '/memory', '/apps', '/settings', '/docs', + '/agents', '/mcp', '/knowledge', '/apps', '/settings', '/docs', ]) // The test server always keeps built-in skills, so /skill is offered. expect(names.slice(15, 18)).toEqual(['/attach', '/skill', '/skills']) @@ -62,6 +62,17 @@ test.describe('Home slash menu', () => { } }) + test('/knowledge is the name and /memory still finds the same command', async ({ page }) => { + await ready(page) + for (const typed of ['/knowl', '/memory']) { + await page.locator('.home-textarea').fill(typed) + await expect(page.getByTestId('home-slash-memory')).toBeVisible() + await expect(page.getByTestId('home-slash-memory')).toContainText('/knowledge') + } + await page.getByTestId('home-slash-memory').click() + await expect(page).toHaveURL(/\/app\/collections$/) + }) + test('the /skills command opens the Skills page', async ({ page }) => { await ready(page) await page.locator('.home-textarea').fill('/skills') diff --git a/core/http/react-ui/e2e/settings-memory.spec.js b/core/http/react-ui/e2e/settings-memory.spec.js index 905971c12e..72dd1f5c5e 100644 --- a/core/http/react-ui/e2e/settings-memory.spec.js +++ b/core/http/react-ui/e2e/settings-memory.spec.js @@ -45,6 +45,10 @@ function listing(s) { if (agent.length) groups.push({ source: 'notes-agent', scope: 'agent', key: 'bot', max_bytes: 2048, max_lines: 40, files: agent }) const prof = has('LocalAI/agents/bot/profile.md') ? [file('LocalAI/agents/bot/profile.md', s.texts['LocalAI/agents/bot/profile.md'].length, { index: true, lines: 1 })] : [] if (prof.length) groups.push({ source: 'profile', scope: 'agent', key: 'bot', max_bytes: 4096, files: prof }) + if (s.texts['Memory/channels/chat-1/index.md'] !== undefined) { + const p = 'Memory/channels/chat-1/index.md' + groups.push({ source: 'notes-channel', scope: 'channel', key: 'chat-1', max_bytes: 2048, max_lines: 40, files: [file(p, s.texts[p].length, { index: true, lines: 1 })] }) + } return { limits: LIMITS, groups } } @@ -189,3 +193,20 @@ test('on a phone the page fits and the rows keep their buttons', async ({ page } expect(overflow).toBeLessThanOrEqual(0) await shot(page, 'memory-page-phone', { fullPage: true }) }) + +test('the Conversations tab lists the notes saved from messaging chats, to read and delete', async ({ page }) => { + const s = fixture() + s.texts['Memory/channels/chat-1/index.md'] = '- coffee: black\n' + await open(page, s) + const tab = page.getByTestId('memory-tabs').getByRole('tab', { name: 'Conversations' }) + await tab.click() + await expect(page.getByTestId('memory-channels-note')).toContainText('not paired') + const g = group(page, 'notes-channel:chat-1') + await expect(g.getByRole('heading')).toHaveText('Notes of conversation chat-1') + await g.locator('.mt-file__name').click() + await expect(g.getByTestId('memory-text')).toContainText('coffee: black') + await shot(page, 'memory-conversations') + await g.getByTestId('memory-delete').click() + await g.getByTestId('memory-delete-yes').click() + await expect.poll(() => s.removed).toEqual(['Memory/channels/chat-1/index.md']) +}) diff --git a/core/http/react-ui/public/locales/de/chats.json b/core/http/react-ui/public/locales/de/chats.json index 63f21a64d5..0ca7158ca0 100644 --- a/core/http/react-ui/public/locales/de/chats.json +++ b/core/http/react-ui/public/locales/de/chats.json @@ -841,6 +841,7 @@ "mine": "Meine", "deleted": "Gelöschte Agenten", "channels": "Unterhaltungen", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "Diese Notizen exportieren", "clearAgent": "Die Notizen dieses Agenten löschen", "clearDeleted": "Diese Notizen löschen" diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json index 9ea59fbdb8..34be4fc7fc 100644 --- a/core/http/react-ui/public/locales/en/chats.json +++ b/core/http/react-ui/public/locales/en/chats.json @@ -1163,6 +1163,7 @@ "mine": "Mine", "deleted": "Deleted agents", "channels": "Conversations", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "Export these notes", "clearAgent": "Delete the notes of this agent", "clearDeleted": "Delete these notes" diff --git a/core/http/react-ui/public/locales/es/chats.json b/core/http/react-ui/public/locales/es/chats.json index c2562d1410..b3a6e44aaa 100644 --- a/core/http/react-ui/public/locales/es/chats.json +++ b/core/http/react-ui/public/locales/es/chats.json @@ -841,6 +841,7 @@ "mine": "Mías", "deleted": "Agentes eliminados", "channels": "Conversaciones", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "Exportar estas notas", "clearAgent": "Eliminar las notas de este agente", "clearDeleted": "Eliminar estas notas" diff --git a/core/http/react-ui/public/locales/fr/chats.json b/core/http/react-ui/public/locales/fr/chats.json index d5226fb2dd..dcc05758dd 100644 --- a/core/http/react-ui/public/locales/fr/chats.json +++ b/core/http/react-ui/public/locales/fr/chats.json @@ -841,6 +841,7 @@ "mine": "Les miennes", "deleted": "Agents supprimés", "channels": "Conversations", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "Exporter ces notes", "clearAgent": "Supprimer les notes de cet agent", "clearDeleted": "Supprimer ces notes" diff --git a/core/http/react-ui/public/locales/id/chats.json b/core/http/react-ui/public/locales/id/chats.json index fb50eed2f7..641133d4e2 100644 --- a/core/http/react-ui/public/locales/id/chats.json +++ b/core/http/react-ui/public/locales/id/chats.json @@ -841,6 +841,7 @@ "mine": "Milik saya", "deleted": "Agen yang dihapus", "channels": "Percakapan", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "Ekspor catatan ini", "clearAgent": "Hapus catatan agen ini", "clearDeleted": "Hapus catatan ini" diff --git a/core/http/react-ui/public/locales/it/chats.json b/core/http/react-ui/public/locales/it/chats.json index 45e9d8de2b..7a57d4ae96 100644 --- a/core/http/react-ui/public/locales/it/chats.json +++ b/core/http/react-ui/public/locales/it/chats.json @@ -841,6 +841,7 @@ "mine": "Mie", "deleted": "Agenti eliminati", "channels": "Conversazioni", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "Esporta queste note", "clearAgent": "Elimina le note di questo agente", "clearDeleted": "Elimina queste note" diff --git a/core/http/react-ui/public/locales/ko/chats.json b/core/http/react-ui/public/locales/ko/chats.json index b620e119ab..422ea35af8 100644 --- a/core/http/react-ui/public/locales/ko/chats.json +++ b/core/http/react-ui/public/locales/ko/chats.json @@ -841,6 +841,7 @@ "mine": "내 메모", "deleted": "삭제된 에이전트", "channels": "대화", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "이 메모 내보내기", "clearAgent": "이 에이전트의 메모 삭제", "clearDeleted": "이 메모 삭제" diff --git a/core/http/react-ui/public/locales/pt-BR/chats.json b/core/http/react-ui/public/locales/pt-BR/chats.json index cc630faf93..c9b76ae456 100644 --- a/core/http/react-ui/public/locales/pt-BR/chats.json +++ b/core/http/react-ui/public/locales/pt-BR/chats.json @@ -841,6 +841,7 @@ "mine": "Minhas", "deleted": "Agentes excluídos", "channels": "Conversas", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "Exportar estas notas", "clearAgent": "Excluir as notas deste agente", "clearDeleted": "Excluir estas notas" diff --git a/core/http/react-ui/public/locales/zh-CN/chats.json b/core/http/react-ui/public/locales/zh-CN/chats.json index f9cbbb9428..3f464987f1 100644 --- a/core/http/react-ui/public/locales/zh-CN/chats.json +++ b/core/http/react-ui/public/locales/zh-CN/chats.json @@ -841,6 +841,7 @@ "mine": "我的", "deleted": "已删除的智能体", "channels": "对话", + "channelsNote": "Notes saved from chats in messaging apps. Open a note to read it, or delete it. A note from a sender who is not paired with you always waits for your approval.", "export": "导出这些笔记", "clearAgent": "删除此智能体的笔记", "clearDeleted": "删除这些笔记" diff --git a/core/http/react-ui/src/components/MemoryTab.jsx b/core/http/react-ui/src/components/MemoryTab.jsx index 68cd29e151..2984161584 100644 --- a/core/http/react-ui/src/components/MemoryTab.jsx +++ b/core/http/react-ui/src/components/MemoryTab.jsx @@ -219,6 +219,7 @@ export default function MemoryTab({ addToast, embedded = false }) { {tabs.length > 1 && }
1 ? 'tabpanel' : undefined} aria-labelledby={tabs.length > 1 ? `mt-tab-${tab.id}` : undefined} className="mt-panelgroup" data-testid="memory-tabpanel" data-tab={tab.id}> {tab.kind === 'deleted' &&

{t('memoryPage.deletedNote')}

} + {tab.kind === 'channels' &&

{t('memoryPage.tabs.channelsNote')}

} {tab.groups.length === 0 &&

{t('memoryPage.tabEmpty')}

} {tab.groups.map((g) => (
diff --git a/core/http/react-ui/src/components/home/HomeComposer.jsx b/core/http/react-ui/src/components/home/HomeComposer.jsx index b075ae11bc..e073c69ed2 100644 --- a/core/http/react-ui/src/components/home/HomeComposer.jsx +++ b/core/http/react-ui/src/components/home/HomeComposer.jsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' // eslint-disable-next-line no-unused-vars import { useTranslation, Trans } from 'react-i18next' -import { availableActions, SLASH_GROUPS } from './homeActions' +import { availableActions, commandNames, SLASH_GROUPS } from './homeActions' import { filterCommands, slashQuery } from '../../utils/slashCommands' import Icon from '../Icon' // eslint-disable-next-line no-unused-vars @@ -101,7 +101,7 @@ export default function HomeComposer({ const open = sq !== null && items.length > 0 && !dismissed // The row that starts selected: the first command whose name starts with // what was typed, before those that only match by their label. - const preferred = query ? Math.max(0, items.findIndex((a) => a.cmd?.slice(1).toLowerCase().startsWith(query))) : 0 + const preferred = query ? Math.max(0, items.findIndex((a) => a.cmd && commandNames(a).some((n) => n.startsWith(query)))) : 0 const safeActive = Math.min(active === null ? preferred : active, Math.max(0, items.length - 1)) useEffect(() => { setActive(null); setDismissed(false) }, [message]) diff --git a/core/http/react-ui/src/components/home/homeActions.js b/core/http/react-ui/src/components/home/homeActions.js index 8f38ec4516..05aa1c1c99 100644 --- a/core/http/react-ui/src/components/home/homeActions.js +++ b/core/http/react-ui/src/components/home/homeActions.js @@ -17,7 +17,9 @@ export const SLASH_ACTIONS = [ { id: 'stop', cmd: '/stop', icon: 'stop', group: 'models', needs: 'loaded' }, { id: 'agents', cmd: '/agents', icon: 'robot', group: 'go', needs: 'chats' }, { id: 'mcp', cmd: '/mcp', icon: 'plug', group: 'go', needs: 'chats' }, - { id: 'memory', cmd: '/memory', icon: 'database', group: 'go', needs: 'collections' }, + // The id stays 'memory' (stored links and tests use it); the name is /knowledge and + // /memory still works as an alias. + { id: 'memory', cmd: '/knowledge', aliases: ['/memory'], icon: 'database', group: 'go', needs: 'collections' }, { id: 'apps', cmd: '/apps', icon: 'puzzle', group: 'go', needs: 'addons' }, { id: 'settings', cmd: '/settings', icon: 'settings', group: 'go', needs: 'admin' }, { id: 'docs', cmd: '/docs', icon: 'book', group: 'go' }, @@ -38,12 +40,15 @@ export function availableActions(ctx) { }) } +// Every name a command answers to, without the slash: its own, then its aliases. +export const commandNames = (a) => [a.cmd, ...(a.aliases || [])].map(n => n.slice(1).toLowerCase()) + // `query` is what follows the slash, lowercase. A command matches when its name // starts with the query or its label contains it. Order stays grouped. export function filterActions(actions, query, labelOf, groups = SLASH_GROUPS) { const q = query.trim() const matched = q - ? actions.filter(a => a.cmd.slice(1).startsWith(q) || labelOf(a).toLowerCase().includes(q)) + ? actions.filter(a => commandNames(a).some(n => n.startsWith(q)) || labelOf(a).toLowerCase().includes(q)) : actions return groups.flatMap(g => matched.filter(a => a.group === g)) } diff --git a/core/http/react-ui/src/utils/slashCommands.js b/core/http/react-ui/src/utils/slashCommands.js index 4e0b30284d..c7cd37d98f 100644 --- a/core/http/react-ui/src/utils/slashCommands.js +++ b/core/http/react-ui/src/utils/slashCommands.js @@ -32,7 +32,7 @@ import { projectHomeCommands } from '../addons/homeCommands.js' // args provider; "/loop stop " is a provider that lists the loops. import { CHAT_SLASH_ACTIONS, CHAT_SLASH_GROUPS } from '../components/chat/chatActions.js' -import { SLASH_ACTIONS, SLASH_GROUPS, availableActions } from '../components/home/homeActions.js' +import { SLASH_ACTIONS, SLASH_GROUPS, availableActions, commandNames } from '../components/home/homeActions.js' import { shortId } from './chatLoops.js' // The groups of the new commands, after the page's own. @@ -131,7 +131,7 @@ export function skillBadge(s) { // the registry, on any surface. export function reservedNames() { const names = new Set() - for (const a of [...CHAT_SLASH_ACTIONS, ...SLASH_ACTIONS, ...SHARED_COMMANDS]) names.add(a.cmd.slice(1)) + for (const a of [...CHAT_SLASH_ACTIONS, ...SLASH_ACTIONS, ...SHARED_COMMANDS]) for (const n of commandNames(a)) names.add(n) return names } @@ -231,7 +231,7 @@ export function slashQuery(text, commands) { export function filterCommands(commands, query, labelOf, groups) { const q = (query || '').trim() const matched = q - ? commands.filter((a) => a.cmd.slice(1).toLowerCase().startsWith(q) || String(labelOf(a) || '').toLowerCase().includes(q)) + ? commands.filter((a) => commandNames(a).some((n) => n.startsWith(q)) || String(labelOf(a) || '').toLowerCase().includes(q)) : commands return groups.flatMap((g) => matched.filter((a) => a.group === g)) }