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 <noreply@anthropic.com>
This commit is contained in:
Ettore Di GiacintoandClaude Sonnet 5.5 committed 2026-11-05 00:42:58 +00:00
1 parent f79d4ef29f
commit 69bbd61b44
15 files changed
+55 -8

No files matched your search

+12 -1
View File
@@ -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')
@@ -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'])
})
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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": "이 메모 삭제"
@@ -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"
@@ -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": "删除这些笔记"
@@ -219,6 +219,7 @@ export default function MemoryTab({ addToast, embedded = false }) {
{tabs.length > 1 && <Tabs tabs={tabs} active={tab.id} onPick={pick} t={t} />}
<div id="mt-tabpanel" role={tabs.length > 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' && <p className="mt-lede" data-testid="memory-deleted-note">{t('memoryPage.deletedNote')}</p>}
{tab.kind === 'channels' && <p className="mt-lede" data-testid="memory-channels-note">{t('memoryPage.tabs.channelsNote')}</p>}
{tab.groups.length === 0 && <p className="mt-lede" data-testid="memory-tab-empty">{t('memoryPage.tabEmpty')}</p>}
{tab.groups.map((g) => (
<section key={groupId(g)} className="mt-group" aria-labelledby={`mt-${groupId(g)}`} data-testid="memory-group" data-group={groupId(g)}>
@@ -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])
+7 -2
View File
@@ -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))
}
+3 -3
View File
@@ -32,7 +32,7 @@ import { projectHomeCommands } from '../addons/homeCommands.js'
// args provider; "/loop stop <id>" 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))
}