mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
feat(ui): add a Memory tab to the account settings
The tab lists the context files that the host puts in the prompt (the user's index and topics, each agent's and conversation's notes, and the agent profiles) with their sizes against the caps. The owner can read, edit, delete and clear them; an index over its cap cannot be saved. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
12 files changed
+260
-9
No files matched your search
@@ -55,7 +55,8 @@
|
||||
"security": "Sicherheit",
|
||||
"apiKeys": "API-Schlüssel",
|
||||
"about": "Über dich und den Assistenten",
|
||||
"appearance": "Darstellung"
|
||||
"appearance": "Darstellung",
|
||||
"memory": "Gedächtnis"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "Anzeigename",
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
"apiKeys": "API keys",
|
||||
"usage": "Usage",
|
||||
"about": "About you and the assistant",
|
||||
"appearance": "Appearance"
|
||||
"appearance": "Appearance",
|
||||
"memory": "Memory"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "Display name",
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"security": "Seguridad",
|
||||
"apiKeys": "Claves API",
|
||||
"about": "Sobre ti y el asistente",
|
||||
"appearance": "Apariencia"
|
||||
"appearance": "Apariencia",
|
||||
"memory": "Memoria"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "Nombre para mostrar",
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"security": "Sécurité",
|
||||
"apiKeys": "Clés d'API",
|
||||
"about": "À propos de vous et de l'assistant",
|
||||
"appearance": "Apparence"
|
||||
"appearance": "Apparence",
|
||||
"memory": "Mémoire"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "Nom affiché",
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"security": "Keamanan",
|
||||
"apiKeys": "API Key",
|
||||
"about": "Tentang Anda dan asisten",
|
||||
"appearance": "Tampilan"
|
||||
"appearance": "Tampilan",
|
||||
"memory": "Memori"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "Nama tampilan",
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"security": "Sicurezza",
|
||||
"apiKeys": "Chiavi API",
|
||||
"about": "Su di te e sull'assistente",
|
||||
"appearance": "Aspetto"
|
||||
"appearance": "Aspetto",
|
||||
"memory": "Memoria"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "Nome visualizzato",
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"security": "보안",
|
||||
"apiKeys": "API 키",
|
||||
"about": "나와 어시스턴트에 대하여",
|
||||
"appearance": "모양"
|
||||
"appearance": "모양",
|
||||
"memory": "메모리"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "표시 이름",
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"security": "Segurança",
|
||||
"apiKeys": "Chaves de API",
|
||||
"about": "Sobre você e o assistente",
|
||||
"appearance": "Aparência"
|
||||
"appearance": "Aparência",
|
||||
"memory": "Memória"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "Nome de exibição",
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"security": "安全",
|
||||
"apiKeys": "API 密钥",
|
||||
"about": "关于您和助手",
|
||||
"appearance": "外观"
|
||||
"appearance": "外观",
|
||||
"memory": "记忆"
|
||||
},
|
||||
"profile": {
|
||||
"displayName": "显示名称",
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
|
||||
import { useCallback, useEffect, useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { chatChangesApi, filesApi } from '../utils/api'
|
||||
import { byteText } from '../utils/changeProposal'
|
||||
import { allFiles, groupId, groupKind, indexFits, indexUse } from '../utils/memoryFiles'
|
||||
import Icon from './Icon'
|
||||
import { Button, Callout, EmptyState, LoadError, SkeletonRows } from './ui'
|
||||
import './memorytab.css'
|
||||
|
||||
const READ_LIMIT = 64 * 1024
|
||||
const path = (p) => (p.startsWith('/') ? p : `/${p}`)
|
||||
|
||||
// The size of an index against its caps, as a bar and words. The bar is a
|
||||
// progress element, so it is read as "x of y" without a style.
|
||||
function Use({ group, t }) {
|
||||
const u = indexUse(group)
|
||||
if (!u.index || !u.maxBytes) return null
|
||||
const words = t(u.maxLines ? 'memoryPage.use' : 'memoryPage.useBytes', { size: byteText(u.bytes), max: byteText(u.maxBytes), lines: u.lines, maxLines: u.maxLines })
|
||||
return (
|
||||
<p className={`mt-use${u.over ? ' mt-use--over' : ''}`} data-testid="memory-use">
|
||||
<progress className="mt-use__bar" value={Math.min(u.bytes, u.maxBytes)} max={u.maxBytes} aria-label={t('memoryPage.useLabel')} />
|
||||
<span>{words}</span>
|
||||
{u.over && <b>{' '}{t('memoryPage.over')}</b>}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// One file of a group: its name and size, and a panel that shows the text and
|
||||
// switches to an editor. The owner writes straight into their own files here;
|
||||
// the host keeps every write as a change set, and a delete goes to the Trash.
|
||||
function FileRow({ file, group, t, busy, onDelete, onSaved, setError }) {
|
||||
const uid = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [text, setText] = useState(null)
|
||||
const [edit, setEdit] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [ask, setAsk] = useState(false)
|
||||
|
||||
const toggle = async () => {
|
||||
if (open) { setOpen(false); setEdit(null); return }
|
||||
setOpen(true)
|
||||
if (text === null) {
|
||||
try { setText(await filesApi.readText(path(file.path), READ_LIMIT)) } catch (e) { setText(''); setError(e.message) }
|
||||
}
|
||||
}
|
||||
const fit = file.index && edit !== null ? indexFits(group, edit) : null
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await filesApi.uploadWithProgress(path(file.path), new Blob([edit], { type: 'text/markdown' }), { replace: true })
|
||||
setText(edit)
|
||||
setEdit(null)
|
||||
onSaved(file)
|
||||
} catch (e) {
|
||||
setError(t('memoryPage.saveFailed', { message: e.message }))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="mt-file" data-testid="memory-file" data-path={file.path}>
|
||||
<div className="mt-file__row">
|
||||
<button type="button" className="mt-file__name" aria-expanded={open} aria-controls={`${uid}-panel`} onClick={toggle}>
|
||||
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
|
||||
<span>{file.name}</span>
|
||||
{file.index && <span className="mt-tag">{t('memoryPage.index')}</span>}
|
||||
</button>
|
||||
<span className="mt-file__size">{byteText(file.size)}</span>
|
||||
{ask ? (
|
||||
<span className="mt-file__acts" role="group" aria-label={t('memoryPage.confirmDelete', { name: file.name })}>
|
||||
<Button size="sm" variant="danger" disabled={busy} data-testid="memory-delete-yes" onClick={() => { setAsk(false); onDelete(file) }}>{t('memoryPage.deleteYes')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAsk(false)}>{t('proposal.cancelEdit')}</Button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-file__acts">
|
||||
<Button size="sm" variant="ghost" icon="trash" disabled={busy} aria-label={t('memoryPage.delete', { name: file.name })} title={t('memoryPage.delete', { name: file.name })} data-testid="memory-delete" onClick={() => setAsk(true)} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="mt-panel" id={`${uid}-panel`}>
|
||||
{text === null ? <SkeletonRows count={2} /> : edit === null ? (
|
||||
<>
|
||||
<pre className="mt-text" tabIndex={0} aria-label={file.name} data-testid="memory-text">{text || t('memoryPage.emptyFile')}</pre>
|
||||
<Button size="sm" icon="edit" data-testid="memory-edit" onClick={() => setEdit(text)}>{t('memoryPage.edit')}</Button>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={(e) => { e.preventDefault(); if (!fit || fit.fits) save() }} onKeyDown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); setEdit(null) } }}>
|
||||
<label className="dk-label" htmlFor={`${uid}-ta`}>{t('memoryPage.editLabel', { name: file.name })}</label>
|
||||
<textarea id={`${uid}-ta`} className="dk-textarea mt-edit" rows={Math.min(16, Math.max(6, edit.split('\n').length + 1))} value={edit} spellCheck={false} autoFocus data-testid="memory-edit-text" aria-describedby={`${uid}-n`} onChange={(e) => setEdit(e.target.value)} />
|
||||
<p className={`mt-help${fit && !fit.fits ? ' mt-help--err' : ''}`} id={`${uid}-n`} data-testid="memory-edit-size">
|
||||
{fit
|
||||
? t(group.max_lines ? 'memoryPage.editSizeIndex' : 'memoryPage.editSize', { size: byteText(fit.bytes), max: byteText(group.max_bytes), lines: fit.lines, maxLines: group.max_lines })
|
||||
: byteText(new TextEncoder().encode(edit).length)}
|
||||
{fit && !fit.fits && <b>{' '}{t('memoryPage.tooBig')}</b>}
|
||||
</p>
|
||||
<span className="mt-acts">
|
||||
<Button type="submit" variant="primary" size="sm" busy={saving} disabled={fit && !fit.fits}>{t('memoryPage.save')}</Button>
|
||||
<Button size="sm" onClick={() => setEdit(null)}>{t('proposal.cancelEdit')}</Button>
|
||||
</span>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function groupTitle(g, t) {
|
||||
const kind = groupKind(g)
|
||||
return t(`memoryPage.group.${kind}`, { key: g.key })
|
||||
}
|
||||
|
||||
// Settings > Memory: the notes the host puts in the prompt of a chat, with
|
||||
// their sizes against the caps that keep a turn small. It lists the files of
|
||||
// every source the host knows (the user's, each agent's, each connector
|
||||
// conversation's) and the agent profiles. The owner can read, edit and delete
|
||||
// them here; a model changes them only through a confirmed proposal.
|
||||
export default function MemoryTab({ addToast }) {
|
||||
const { t } = useTranslation('chats')
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [note, setNote] = useState('')
|
||||
const [fail, setFail] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [clear, setClear] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await chatChangesApi.contextFiles())
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const run = async (call, say) => {
|
||||
setBusy(true)
|
||||
setFail('')
|
||||
try {
|
||||
await call()
|
||||
setNote(say)
|
||||
addToast?.(say, 'success')
|
||||
} catch (e) {
|
||||
setFail(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
await load()
|
||||
}
|
||||
}
|
||||
const remove = (file) => run(() => filesApi.remove(path(file.path)), t('memoryPage.deleted', { name: file.name }))
|
||||
const removeAll = async () => {
|
||||
const files = allFiles(data?.groups)
|
||||
setClear(false)
|
||||
await run(async () => { for (const f of files) await filesApi.remove(path(f.path)) }, t('memoryPage.cleared', { count: files.length }))
|
||||
}
|
||||
|
||||
if (error) return <LoadError title={t('memoryPage.loadError')} error={error} onRetry={load} testId="memory-load-error" />
|
||||
if (!data) return <div data-testid="memory-tab"><SkeletonRows count={3} /></div>
|
||||
|
||||
const groups = data.groups || []
|
||||
const total = allFiles(groups).length
|
||||
const lim = data.limits || {}
|
||||
return (
|
||||
<div className="mt-tab" data-testid="memory-tab">
|
||||
<p className="mt-lede">{t('memoryPage.lede')}</p>
|
||||
<p className="mt-limits" data-testid="memory-limits">
|
||||
{t('memoryPage.limits', { lines: lim.index_lines, index: byteText(lim.index_bytes), profile: byteText(lim.profile_bytes), turn: byteText(lim.turn_bytes) })}
|
||||
</p>
|
||||
<p className="mt-lede"><Link className="cs-link" to="/app/chats/proposals">{t('memoryPage.proposalsLink')}</Link></p>
|
||||
<div className="sr-only" role="status" aria-live="polite">{note}</div>
|
||||
{fail && <p className="mt-help mt-help--err" role="alert" data-testid="memory-error">{fail}</p>}
|
||||
{groups.length === 0 ? (
|
||||
<EmptyState variant="first-run" icon="memory" title={t('memoryPage.emptyTitle')} body={t('memoryPage.emptyBody')} testId="memory-empty" />
|
||||
) : (
|
||||
<>
|
||||
{groups.map((g) => (
|
||||
<section key={groupId(g)} className="mt-group" aria-labelledby={`mt-${groupId(g)}`} data-testid="memory-group" data-group={groupId(g)}>
|
||||
<h3 className="mt-group__title" id={`mt-${groupId(g)}`}>{groupTitle(g, t)}</h3>
|
||||
<Use group={g} t={t} />
|
||||
<ul className="mt-files">
|
||||
{g.files.map((f) => (
|
||||
<FileRow key={f.path} file={f} group={g} t={t} busy={busy} onDelete={remove} setError={setFail}
|
||||
onSaved={(file) => { setNote(t('memoryPage.saved', { name: file.name })); load() }} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
<div className="mt-foot">
|
||||
{clear ? (
|
||||
<Callout tone="warn" role="alertdialog" testId="memory-clear-confirm"
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="danger" size="sm" autoFocus busy={busy} data-testid="memory-clear-yes" onClick={removeAll}>{t('memoryPage.clearYes', { count: total })}</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setClear(false)}>{t('proposal.cancelEdit')}</Button>
|
||||
</>
|
||||
)}
|
||||
>{t('memoryPage.clearAsk', { count: total })}</Callout>
|
||||
) : (
|
||||
<Button variant="danger" size="sm" icon="trash" disabled={busy} data-testid="memory-clear" onClick={() => setClear(true)}>{t('memoryPage.clear')}</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Settings > Memory: the context files of the prompt, with their sizes. */
|
||||
.mt-tab { display: grid; gap: var(--sp-4); max-width: 720px; }
|
||||
.mt-lede, .mt-limits { margin: 0; color: var(--dk-muted); font-size: var(--text-sm); }
|
||||
.mt-limits { font-size: var(--text-xs); }
|
||||
.mt-group { display: grid; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--dk-radius-2); background: var(--dk-card); box-shadow: inset 0 0 0 1px var(--dk-edge); }
|
||||
.mt-group__title { margin: 0; font-size: var(--text-md, 15px); color: var(--dk-text); overflow-wrap: anywhere; }
|
||||
.mt-use { display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--sp-3); margin: 0; font-size: var(--text-xs); color: var(--dk-muted); }
|
||||
.mt-use__bar { width: 120px; height: 6px; accent-color: var(--dk-accent); }
|
||||
.mt-use--over { color: var(--dk-error-text, var(--dk-error)); }
|
||||
.mt-use--over .mt-use__bar { accent-color: var(--dk-error); }
|
||||
.mt-files { margin: 0; padding: 0; list-style: none; display: grid; }
|
||||
.mt-file { border-top: 1px solid var(--dk-edge); }
|
||||
.mt-file__row { display: flex; align-items: center; gap: var(--sp-3); padding: 4px 0; }
|
||||
.mt-file__name { display: flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; padding: 6px 0; border: 0; background: none; color: var(--dk-text); font: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
||||
.mt-file__name:focus-visible { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; border-radius: var(--dk-radius-1); }
|
||||
.mt-file__size { flex: none; color: var(--dk-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
||||
.mt-file__acts { flex: none; display: flex; gap: 4px; }
|
||||
.mt-tag { padding: 0 6px; border-radius: var(--dk-radius-3); background: var(--dk-accent-wash); color: var(--dk-accent-text); font-size: var(--text-xs); }
|
||||
.mt-panel { display: grid; gap: var(--sp-2); padding: 0 0 var(--sp-3); }
|
||||
.mt-text { margin: 0; padding: var(--sp-3); max-height: 260px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; border-radius: var(--dk-radius-2); background: var(--dk-inset); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.mt-edit { width: 100%; font-family: var(--font-mono); font-size: var(--text-xs); resize: vertical; }
|
||||
.mt-foot { display: flex; }
|
||||
.mt-edit { height: auto; padding: var(--sp-2) var(--sp-3); }
|
||||
.mt-help { margin: var(--sp-1) 0 var(--sp-2); font-size: var(--text-xs); color: var(--dk-muted); }
|
||||
.mt-help--err { color: var(--dk-error-text, var(--dk-error)); }
|
||||
.mt-acts { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
.mt-panel .dk-label { display: block; margin-bottom: var(--sp-1); }
|
||||
@@ -9,6 +9,7 @@ import { timeUntil, initialOf } from '../utils/access'
|
||||
import { Button, Callout, EmptyState, LoadError, PageHeader, SkeletonRows, useLoad } from '../components/ui'
|
||||
import ApiKeysPanel from '../components/users/ApiKeysPanel'
|
||||
import { AboutYouTab, AppearanceTab } from '../components/wellknown/SettingsTabs'
|
||||
import MemoryTab from '../components/MemoryTab'
|
||||
import './access.css'
|
||||
|
||||
const TABS = [
|
||||
@@ -17,6 +18,7 @@ const TABS = [
|
||||
{ id: 'keys', labelKey: 'account.tabs.apiKeys' },
|
||||
{ id: 'usage', labelKey: 'account.tabs.usage' },
|
||||
{ id: 'about', labelKey: 'account.tabs.about', files: true },
|
||||
{ id: 'memory', labelKey: 'account.tabs.memory', files: true },
|
||||
{ id: 'appearance', labelKey: 'account.tabs.appearance', files: true },
|
||||
]
|
||||
|
||||
@@ -298,6 +300,7 @@ export default function Account() {
|
||||
{tab === 'keys' && <ApiKeysPanel addToast={addToast} intro={t('keys.accountIntro')} />}
|
||||
{tab === 'usage' && <UsageTab />}
|
||||
{tab === 'about' && <AboutYouTab />}
|
||||
{tab === 'memory' && <MemoryTab addToast={addToast} />}
|
||||
{tab === 'appearance' && <AppearanceTab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user