Memory page and search inside chats, files and memory (Ctrl+K, GET /search)
ci / ui (push) Waiting to run
ci / check (push) Failing after 7s
ci / start (push) Failing after 5s

The account's memory as the web's Settings -> Memory keeps it: add, change,
remove, over /api/v2/memories. Ctrl+K asks the server's search once two
letters are typed and lists the hits below the titles, the matched ranges
drawn as <mark> elements over plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 13:44:49 +00:00
1 parent be70e1a628
commit d691a4dcfa
9 files changed
+515 -11

No files matched your search

+7
View File
@@ -24,6 +24,13 @@ All notable changes to LLeMbas Desktop. The format follows
folders, nested and folded as the server keeps them, then the rest by day. A chat's menu pins
it and moves it into a folder or a new one; a folder's menu renames it, makes one inside it, or
deletes it (its chats stay, back in the list). The search line still lists every match.
- **Memory.** A page in the sidebar (and in Ctrl+K) lists what the account's models remember about
the person, as the web's Settings → Memory does: add a record (into a data group, where there is
more than one), change it, remove it. A record a model noted says so.
- **Search inside.** Ctrl+K finds more than titles: once two letters are typed it asks the
server's search, and lists below the titles what it found inside chats, in the file library and
in memory, each with the words around the match, the match marked. A chat opens, a file opens
in the viewer, a memory opens the Memory page at it. A server without search finds titles only.
### Fixed
+65
View File
@@ -484,6 +484,15 @@ KEYS = (
"New folder…",
"Create",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.",
# palette.js, memory.js: search inside, and the memory page
"Memory",
"Add",
"Data group",
"Remove this memory?",
"Noted by a model",
"What your models remember about you, across chats. Change or remove anything here.",
"Something to remember…",
"Nothing remembered yet.",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
@@ -732,6 +741,13 @@ OWN: dict[str, dict[str, str]] = {
"New folder…": "Nová složka…",
"Create": "Vytvořit",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a složky v ní se smažou. Jejich konverzace zůstanou, zpět v seznamu.",
"Add": "Přidat",
"Data group": "Skupina dat",
"Remove this memory?": "Odstranit tento záznam z paměti?",
"Noted by a model": "Zaznamenal model",
"What your models remember about you, across chats. Change or remove anything here.": "Co si o vás vaše modely pamatují napříč konverzacemi. Cokoli tu můžete změnit nebo odstranit.",
"Something to remember…": "Něco k zapamatování…",
"Nothing remembered yet.": "Zatím si nic nepamatuje.",
},
"de": {
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
@@ -975,6 +991,13 @@ OWN: dict[str, dict[str, str]] = {
"New folder…": "Neuer Ordner…",
"Create": "Erstellen",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ und die Ordner darin werden gelöscht. Ihre Chats bleiben, wieder in der Liste.",
"Add": "Hinzufügen",
"Data group": "Datengruppe",
"Remove this memory?": "Diese Erinnerung entfernen?",
"Noted by a model": "Von einem Modell notiert",
"What your models remember about you, across chats. Change or remove anything here.": "Was Ihre Modelle über Sie behalten, über alle Chats hinweg. Hier können Sie alles ändern oder entfernen.",
"Something to remember…": "Etwas, das behalten werden soll…",
"Nothing remembered yet.": "Noch nichts behalten.",
},
"es": {
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
@@ -1218,6 +1241,13 @@ OWN: dict[str, dict[str, str]] = {
"New folder…": "Nueva carpeta…",
"Create": "Crear",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "Se eliminan «%(name)s» y las carpetas que contiene. Sus chats se quedan, de vuelta en la lista.",
"Add": "Añadir",
"Data group": "Grupo de datos",
"Remove this memory?": "¿Quitar este recuerdo?",
"Noted by a model": "Anotado por un modelo",
"What your models remember about you, across chats. Change or remove anything here.": "Lo que sus modelos recuerdan de usted entre chats. Aquí puede cambiar o quitar cualquier cosa.",
"Something to remember…": "Algo que recordar…",
"Nothing remembered yet.": "Todavía no se recuerda nada.",
},
"fr": {
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
@@ -1461,6 +1491,13 @@ OWN: dict[str, dict[str, str]] = {
"New folder…": "Nouveau dossier…",
"Create": "Créer",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "« %(name)s » et les dossiers qu’il contient sont supprimés. Leurs conversations restent, de retour dans la liste.",
"Add": "Ajouter",
"Data group": "Groupe de données",
"Remove this memory?": "Supprimer ce souvenir ?",
"Noted by a model": "Noté par un modèle",
"What your models remember about you, across chats. Change or remove anything here.": "Ce que vos modèles retiennent de vous, d’une conversation à l’autre. Modifiez ou supprimez ce que vous voulez ici.",
"Something to remember…": "Quelque chose à retenir…",
"Nothing remembered yet.": "Rien n’est encore retenu.",
},
"hu": {
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
@@ -1704,6 +1741,13 @@ OWN: dict[str, dict[str, str]] = {
"New folder…": "Új mappa…",
"Create": "Létrehozás",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "A(z) „%(name)s” és a benne lévő mappák törlődnek. A beszélgetéseik megmaradnak, vissza a listába.",
"Add": "Hozzáadás",
"Data group": "Adatcsoport",
"Remove this memory?": "Eltávolítja ezt az emléket?",
"Noted by a model": "Egy modell jegyezte fel",
"What your models remember about you, across chats. Change or remove anything here.": "Amit a modelljei megjegyeznek Önről a beszélgetéseken át. Itt bármit módosíthat vagy eltávolíthat.",
"Something to remember…": "Valami, amit meg kell jegyezni…",
"Nothing remembered yet.": "Még nincs megjegyezve semmi.",
},
"it": {
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
@@ -1947,6 +1991,13 @@ OWN: dict[str, dict[str, str]] = {
"New folder…": "Nuova cartella…",
"Create": "Crea",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "«%(name)s» e le cartelle al suo interno vengono eliminate. Le loro chat restano, di nuovo nell’elenco.",
"Add": "Aggiungi",
"Data group": "Gruppo di dati",
"Remove this memory?": "Rimuovere questo ricordo?",
"Noted by a model": "Annotato da un modello",
"What your models remember about you, across chats. Change or remove anything here.": "Ciò che i tuoi modelli ricordano di te tra una chat e l’altra. Qui puoi cambiare o rimuovere qualsiasi cosa.",
"Something to remember…": "Qualcosa da ricordare…",
"Nothing remembered yet.": "Ancora niente da ricordare.",
},
"pl": {
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
@@ -2190,6 +2241,13 @@ OWN: dict[str, dict[str, str]] = {
"New folder…": "Nowy folder…",
"Create": "Utwórz",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s” i foldery w nim zostaną usunięte. Ich czaty zostaną, z powrotem na liście.",
"Add": "Dodaj",
"Data group": "Grupa danych",
"Remove this memory?": "Usunąć to wspomnienie?",
"Noted by a model": "Zanotowane przez model",
"What your models remember about you, across chats. Change or remove anything here.": "Co Twoje modele pamiętają o Tobie we wszystkich czatach. Tutaj możesz wszystko zmienić lub usunąć.",
"Something to remember…": "Coś do zapamiętania…",
"Nothing remembered yet.": "Na razie nic nie zapamiętano.",
},
"sk": {
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
@@ -2433,6 +2491,13 @@ OWN: dict[str, dict[str, str]] = {
"New folder…": "Nový priečinok…",
"Create": "Vytvoriť",
"“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a priečinky v ňom sa zmažú. Ich konverzácie zostanú, späť v zozname.",
"Add": "Pridať",
"Data group": "Skupina údajov",
"Remove this memory?": "Odstrániť tento záznam z pamäte?",
"Noted by a model": "Zaznamenal model",
"What your models remember about you, across chats. Change or remove anything here.": "Čo si o vás vaše modely pamätajú naprieč konverzáciami. Čokoľvek tu môžete zmeniť alebo odstrániť.",
"Something to remember…": "Niečo na zapamätanie…",
"Nothing remembered yet.": "Zatiaľ si nič nepamätá.",
},
}
+93
View File
@@ -1200,6 +1200,99 @@ test("F8: at 390 px the folders stay inside the sidebar", async () => {
assert.deepEqual(p.errors, [])
})
// G8: search inside (GET /search, fixture search.json) and the memory page (memories.json).
function g8(over = {}) {
return {
status: status(),
routes: baseRoutes({
"GET /search": fixture("search"),
"GET /memories": fixture("memories"),
"POST /memories": { memory: { ...fixture("memory").memory, id: "mem2", content: "Walks to Bree on Sundays." } },
"PATCH /memories/mem1": { memory: { ...fixture("memory").memory, content: "Prefers lembas cold." } },
"DELETE /memories/mem1": null,
"GET /files/content": { name: "bread.md", path: "/home/frodo/bread.md", text: "# Lembas recipe", kind: "markdown" },
...over,
}),
streams: {},
}
}
test("G8: the palette finds words inside chats, files and memory through the server, the matches marked as text", async () => {
const p = await page(g8())
await p.keyboard.press("Control+k")
await p.fill(".palette__input", "lembas")
await p.waitForSelector(".palette__snippet")
const [q] = await p.evaluate(() => window.__mock.api("GET", "/search"))
assert.match(q.args.path, /^\/search\?q=lembas&in=all&limit=8$/)
const hits = await p.$$eval(".palette__item:has(.palette__snippet)", (l) => l.map((x) => ({ title: x.querySelector(".palette__title").textContent, mark: x.querySelector("mark")?.textContent, hint: x.querySelector(".palette__hint").textContent })))
assert.deepEqual(hits.map((x) => x.hint), ["Chats", "Files", "Memory"])
assert.equal(hits[0].title, "Waybread")
assert.equal(hits[0].mark, "lembas")
assert.equal(hits[2].mark, "lembas")
await shot(p, "g8-search-1280")
// A memory opens the memory page at it.
await p.click(".palette__item:has(.palette__hint:text('Memory'))")
await p.waitForSelector('#page-memory:not([hidden]) .mrow.is-marked[data-id="mem1"]')
assert.deepEqual(p.errors, [])
})
test("G8: a chat found inside opens; a server without search finds nothing more, and says nothing", async () => {
let p = await page(g8())
await p.keyboard.press("Control+k")
await p.fill(".palette__input", "lembas")
await p.waitForSelector(".palette__snippet")
await p.click(".palette__item:has(.palette__hint:text('Chats'))")
await p.waitForFunction(() => window.__mock.api("GET", "/chats/chat1").length > 0)
await p.context().close()
p = await page(g8({ "GET /search": { __error: { status: 404, detail: "Not Found" } } }))
await p.keyboard.press("Control+k")
await p.fill(".palette__input", "lembas")
await p.waitForFunction(() => window.__mock.api("GET", "/search").length === 1)
await p.waitForTimeout(100)
assert.equal(await p.locator(".palette__snippet").count(), 0)
assert.deepEqual(p.errors, [])
})
test("G8: the memory page adds, changes and removes what the models remember", async () => {
const p = await page(g8())
await p.click("#nav-memory")
await p.waitForSelector('.mrow[data-id="mem1"]')
assert.match(await p.textContent('.mrow[data-id="mem1"]'), /Prefers lembas warm\./)
await p.fill("#memory-new", "Walks to Bree on Sundays.")
await p.click(".mnew button[type=submit]")
await p.waitForSelector('.mrow[data-id="mem2"]')
assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/memories")))[0].args.body, { content: "Walks to Bree on Sundays." })
assert.equal(await p.inputValue("#memory-new"), "")
await p.click('.mrow[data-id="mem1"] .btn >> text=Edit')
await p.fill(".mrow.is-editing textarea", "Prefers lembas cold.")
await p.click(".mrow.is-editing .btn--primary")
await p.waitForFunction(() => /cold/.test(document.querySelector('.mrow[data-id="mem1"]').textContent))
assert.deepEqual((await p.evaluate(() => window.__mock.api("PATCH", "/memories/mem1")))[0].args.body, { content: "Prefers lembas cold." })
await p.click('.mrow[data-id="mem1"] .tool')
await p.click(".menu .menu__item >> text=Remove")
await p.click("dialog[open] .btn--danger")
await p.waitForFunction(() => !document.querySelector('.mrow[data-id="mem1"]'))
assert.deepEqual(p.errors, [])
})
test("G8: at 390 px the memory page and the palette's hits stay inside the window", async () => {
const p = await page(g8(), { width: 390, height: 844 })
await p.evaluate(() => document.getElementById("nav-memory").click())
await p.waitForSelector('.mrow[data-id="mem1"]')
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
await shot(p, "g8-memory-390")
await p.setViewportSize({ width: 1280, height: 860 })
await shot(p, "g8-memory-1280")
await p.setViewportSize({ width: 390, height: 844 })
await p.keyboard.press("Control+k")
await p.fill(".palette__input", "lembas")
await p.waitForSelector(".palette__snippet")
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
await shot(p, "g8-search-390")
assert.deepEqual(p.errors, [])
})
// --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || ""
+27
View File
@@ -1205,6 +1205,11 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.palette__icon { display: grid; place-items: center; color: var(--ink-muted); }
.palette__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.palette__hint { font-size: var(--text-xs); color: var(--ink-faint); white-space: nowrap; }
/* A hit found inside: its title over the snippet, the words that matched marked. */
.palette__label--two { display: grid; padding: var(--sp-1) 0; white-space: normal; }
.palette__title, .palette__snippet { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.palette__snippet { font-size: var(--text-xs); color: var(--ink-muted); }
.palette__snippet mark { color: var(--ink); background: var(--surface-active); border-radius: var(--radius-sm); }
.palette__none { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
@@ -1691,6 +1696,28 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.arch__page { display: grid; gap: var(--sp-3); }
.head__unarchive { white-space: nowrap; }
/* --- Memory: a new one on top, then each record over its group and date · Edit · more. */
.mnew { display: grid; gap: var(--sp-2); }
.mnew textarea { width: 100%; }
.mnew__foot { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; }
.mnew__foot select { justify-self: start; max-width: 100%; }
.mrow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm);
gap: var(--sp-3);
align-items: center;
min-height: var(--control-h);
padding: var(--sp-2) 0;
border-bottom: var(--border-w) solid var(--border);
}
.mrow.is-editing { grid-template-columns: minmax(0, 1fr); }
.mrow.is-editing textarea { width: 100%; }
.mrow.is-marked { background: var(--surface-active); border-radius: var(--radius-lg); padding-inline: var(--sp-2); }
.mrow__words { display: grid; gap: var(--sp-1); min-width: 0; }
.mrow__text { margin: 0; font: var(--text-sm) / var(--leading-normal) var(--font-body); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
.mrow__sub { margin: 0; font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
/* --- Narrower windows ---------------------------------------------------------------------- */
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
+64
View File
@@ -44,6 +44,7 @@ const LEMBAS_I18N = {
"About": "O aplikaci",
"Account": "Účet",
"Activity": "Aktivita",
"Add": "Přidat",
"Add folder…": "Přidat složku…",
"Address of your LLeMbas server": "Adresa vašeho serveru LLeMbas",
"Agent": "Agent",
@@ -105,6 +106,7 @@ const LEMBAS_I18N = {
"Counted by the model at the last reply; the categories are estimated.": "Spočítal model při poslední odpovědi; kategorie jsou odhadnuté.",
"Create": "Vytvořit",
"Cut short here; the whole of it is in the web interface.": "Zde zkráceno; celé je to ve webovém rozhraní.",
"Data group": "Datová skupina",
"Days": "Dny",
"Delete": "Smazat",
"Delete chat": "Smazat konverzaci",
@@ -167,6 +169,7 @@ const LEMBAS_I18N = {
"Manual — asks first": "Manual — nejdřív se zeptá",
"Max": "Max",
"Maximise": "Maximalizovat",
"Memory": "Paměť",
"Memory and personal": "Paměť a osobní",
"Menu": "Nabídka",
"Message": "Zpráva",
@@ -201,10 +204,12 @@ const LEMBAS_I18N = {
"Not measured.": "Neměřeno.",
"Not run yet": "Ještě neběžela",
"Not yet": "Ještě ne",
"Noted by a model": "Zaznamenal model",
"Nothing has been run in this shell yet.": "V tomto shellu se zatím nic nespustilo.",
"Nothing has been said in the room yet.": "V místnosti zatím nikdo nic neřekl.",
"Nothing is scheduled.": "Nic není naplánováno.",
"Nothing matches.": "Nic neodpovídá.",
"Nothing remembered yet.": "Zatím si nic nepamatuje.",
"Nothing running.": "Nic neběží.",
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Na pozadí nic neběží. Podagenti, členové týmu a příkazy na pozadí se tu zobrazí, dokud pracují.",
"Nothing to report": "Nic k hlášení",
@@ -245,6 +250,7 @@ const LEMBAS_I18N = {
"Regenerate reply": "Vygenerovat odpověď znovu",
"Remove": "Odebrat",
"Remove %(dir)s": "Odebrat %(dir)s",
"Remove this memory?": "Odstranit tento záznam z paměti?",
"Rename": "Přejmenovat",
"Rename chat": "Přejmenovat konverzaci",
"Rename folder": "Přejmenovat složku",
@@ -288,6 +294,7 @@ const LEMBAS_I18N = {
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlášením se přihlášení aplikace na serveru zruší; propojení agenta zůstane. Chcete-li použít jiný server, odhlaste se.",
"Since the last reply": "Od poslední odpovědi",
"Skipped": "Vynecháno",
"Something to remember…": "Něco k zapamatování…",
"Source": "Zdroj",
"Start the agent": "Spustit agenta",
"Stop": "Zastavit",
@@ -382,6 +389,7 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Čeká se na {{model}}: {{reason}}",
"What it does": "Co dělá",
"What should happen, and when": "Co se má stát a kdy",
"What your models remember about you, across chats. Change or remove anything here.": "Co si o vás vaše modely pamatují napříč konverzacemi. Cokoli tu můžete změnit nebo odstranit.",
"When": "Kdy",
"When it matters": "Když na tom záleží",
"Writing": "Píše",
@@ -476,6 +484,7 @@ const LEMBAS_I18N = {
"About": "Über",
"Account": "Konto",
"Activity": "Aktivität",
"Add": "Hinzufügen",
"Add folder…": "Ordner hinzufügen…",
"Address of your LLeMbas server": "Adresse Ihres LLeMbas-Servers",
"Agent": "Agent",
@@ -537,6 +546,7 @@ const LEMBAS_I18N = {
"Counted by the model at the last reply; the categories are estimated.": "Vom Modell bei der letzten Antwort gezählt; die Kategorien sind geschätzt.",
"Create": "Erstellen",
"Cut short here; the whole of it is in the web interface.": "Hier gekürzt; vollständig steht es in der Weboberfläche.",
"Data group": "Datengruppe",
"Days": "Tage",
"Delete": "Löschen",
"Delete chat": "Chat löschen",
@@ -599,6 +609,7 @@ const LEMBAS_I18N = {
"Manual — asks first": "Manual — fragt zuerst",
"Max": "Max",
"Maximise": "Maximieren",
"Memory": "Gedächtnis",
"Memory and personal": "Gedächtnis und Persönliches",
"Menu": "Menü",
"Message": "Nachricht",
@@ -633,10 +644,12 @@ const LEMBAS_I18N = {
"Not measured.": "Nicht gemessen.",
"Not run yet": "Noch nicht gelaufen",
"Not yet": "Noch nicht",
"Noted by a model": "Von einem Modell notiert",
"Nothing has been run in this shell yet.": "In dieser Shell wurde noch nichts ausgeführt.",
"Nothing has been said in the room yet.": "Im Raum wurde noch nichts gesagt.",
"Nothing is scheduled.": "Nichts ist geplant.",
"Nothing matches.": "Nichts gefunden.",
"Nothing remembered yet.": "Noch nichts behalten.",
"Nothing running.": "Nichts läuft.",
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Im Hintergrund läuft nichts. Subagenten, die Mitglieder eines Teams und Hintergrundbefehle erscheinen hier, solange sie arbeiten.",
"Nothing to report": "Nichts zu melden",
@@ -677,6 +690,7 @@ const LEMBAS_I18N = {
"Regenerate reply": "Antwort neu generieren",
"Remove": "Entfernen",
"Remove %(dir)s": "%(dir)s entfernen",
"Remove this memory?": "Diese Erinnerung entfernen?",
"Rename": "Umbenennen",
"Rename chat": "Chat umbenennen",
"Rename folder": "Ordner umbenennen",
@@ -720,6 +734,7 @@ const LEMBAS_I18N = {
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Beim Abmelden gibt die App ihre Anmeldung am Server auf; die Verbindung des Agenten bleibt. Melden Sie sich ab, um einen anderen Server zu verwenden.",
"Since the last reply": "Seit der letzten Antwort",
"Skipped": "Ausgelassen",
"Something to remember…": "Etwas, das behalten werden soll…",
"Source": "Quelltext",
"Start the agent": "Agent starten",
"Stop": "Stoppen",
@@ -814,6 +829,7 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Warte auf {{model}}: {{reason}}",
"What it does": "Was sie tut",
"What should happen, and when": "Was soll wann geschehen?",
"What your models remember about you, across chats. Change or remove anything here.": "Was Ihre Modelle über Sie behalten, über alle Chats hinweg. Hier können Sie alles ändern oder entfernen.",
"When": "Wann",
"When it matters": "Wenn es zählt",
"Writing": "Schreibe",
@@ -908,6 +924,7 @@ const LEMBAS_I18N = {
"About": "Acerca de",
"Account": "Cuenta",
"Activity": "Actividad",
"Add": "Añadir",
"Add folder…": "Añadir carpeta…",
"Address of your LLeMbas server": "Dirección de su servidor LLeMbas",
"Agent": "Agente",
@@ -969,6 +986,7 @@ const LEMBAS_I18N = {
"Counted by the model at the last reply; the categories are estimated.": "Contado por el modelo en la última respuesta; las categorías son estimaciones.",
"Create": "Crear",
"Cut short here; the whole of it is in the web interface.": "Recortado aquí; completo está en la interfaz web.",
"Data group": "Grupo de datos",
"Days": "Días",
"Delete": "Eliminar",
"Delete chat": "Eliminar chat",
@@ -1031,6 +1049,7 @@ const LEMBAS_I18N = {
"Manual — asks first": "Manual — pregunta primero",
"Max": "Máx",
"Maximise": "Maximizar",
"Memory": "Memoria",
"Memory and personal": "Memoria y datos personales",
"Menu": "Menú",
"Message": "Mensaje",
@@ -1065,10 +1084,12 @@ const LEMBAS_I18N = {
"Not measured.": "Sin medir.",
"Not run yet": "Aún no se ha ejecutado",
"Not yet": "Todavía no",
"Noted by a model": "Anotado por un modelo",
"Nothing has been run in this shell yet.": "Aún no se ha ejecutado nada en este shell.",
"Nothing has been said in the room yet.": "Todavía no se ha dicho nada en la sala.",
"Nothing is scheduled.": "No hay nada programado.",
"Nothing matches.": "No hay coincidencias.",
"Nothing remembered yet.": "Todavía no se recuerda nada.",
"Nothing running.": "Nada en marcha.",
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "No hay nada en segundo plano. Los subagentes, los miembros de un equipo y los comandos en segundo plano aparecen aquí mientras trabajan.",
"Nothing to report": "Nada que informar",
@@ -1109,6 +1130,7 @@ const LEMBAS_I18N = {
"Regenerate reply": "Regenerar respuesta",
"Remove": "Quitar",
"Remove %(dir)s": "Quitar %(dir)s",
"Remove this memory?": "¿Quitar este recuerdo?",
"Rename": "Renombrar",
"Rename chat": "Renombrar chat",
"Rename folder": "Renombrar carpeta",
@@ -1152,6 +1174,7 @@ const LEMBAS_I18N = {
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Al cerrar sesión, la aplicación renuncia a su acceso en el servidor; la vinculación del agente se mantiene. Cierre sesión para usar otro servidor.",
"Since the last reply": "Desde la última respuesta",
"Skipped": "Omitida",
"Something to remember…": "Algo que recordar…",
"Source": "Código fuente",
"Start the agent": "Iniciar el agente",
"Stop": "Detener",
@@ -1246,6 +1269,7 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Esperando a {{model}}: {{reason}}",
"What it does": "Qué hace",
"What should happen, and when": "Qué debe ocurrir, y cuándo",
"What your models remember about you, across chats. Change or remove anything here.": "Lo que sus modelos recuerdan de usted entre chats. Aquí puede cambiar o quitar cualquier cosa.",
"When": "Cuándo",
"When it matters": "Cuando importa",
"Writing": "Escribiendo",
@@ -1340,6 +1364,7 @@ const LEMBAS_I18N = {
"About": "À propos",
"Account": "Compte",
"Activity": "Activité",
"Add": "Ajouter",
"Add folder…": "Ajouter un dossier…",
"Address of your LLeMbas server": "Adresse de votre serveur LLeMbas",
"Agent": "Agent",
@@ -1401,6 +1426,7 @@ const LEMBAS_I18N = {
"Counted by the model at the last reply; the categories are estimated.": "Compté par le modèle lors de la dernière réponse ; les catégories sont estimées.",
"Create": "Créer",
"Cut short here; the whole of it is in the web interface.": "Tronqué ici ; le tout est dans l'interface web.",
"Data group": "Groupe de données",
"Days": "Jours",
"Delete": "Supprimer",
"Delete chat": "Supprimer la conversation",
@@ -1463,6 +1489,7 @@ const LEMBAS_I18N = {
"Manual — asks first": "Manual — demande d'abord",
"Max": "Max",
"Maximise": "Agrandir",
"Memory": "Mémoire",
"Memory and personal": "Mémoire et informations personnelles",
"Menu": "Menu",
"Message": "Message",
@@ -1497,10 +1524,12 @@ const LEMBAS_I18N = {
"Not measured.": "Non mesuré.",
"Not run yet": "Pas encore exécutée",
"Not yet": "Pas encore",
"Noted by a model": "Noté par un modèle",
"Nothing has been run in this shell yet.": "Rien n'a encore été exécuté dans ce shell.",
"Nothing has been said in the room yet.": "Rien n’a encore été dit dans la salle.",
"Nothing is scheduled.": "Rien n’est planifié.",
"Nothing matches.": "Aucun résultat.",
"Nothing remembered yet.": "Rien n’est encore retenu.",
"Nothing running.": "Rien en cours.",
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Rien ne tourne en arrière-plan. Les sous-agents, les membres d’une équipe et les commandes en arrière-plan s’affichent ici pendant qu’ils travaillent.",
"Nothing to report": "Rien à signaler",
@@ -1541,6 +1570,7 @@ const LEMBAS_I18N = {
"Regenerate reply": "Régénérer la réponse",
"Remove": "Retirer",
"Remove %(dir)s": "Retirer %(dir)s",
"Remove this memory?": "Supprimer ce souvenir ?",
"Rename": "Renommer",
"Rename chat": "Renommer la conversation",
"Rename folder": "Renommer le dossier",
@@ -1584,6 +1614,7 @@ const LEMBAS_I18N = {
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "La déconnexion abandonne la connexion de l'application au serveur ; la liaison de l'agent reste. Déconnectez-vous pour utiliser un autre serveur.",
"Since the last reply": "Depuis la dernière réponse",
"Skipped": "Ignorée",
"Something to remember…": "Quelque chose à retenir…",
"Source": "Source",
"Start the agent": "Démarrer l'agent",
"Stop": "Arrêter",
@@ -1678,6 +1709,7 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "En attente de {{model}} : {{reason}}",
"What it does": "Ce qu’elle fait",
"What should happen, and when": "Ce qui doit se passer, et quand",
"What your models remember about you, across chats. Change or remove anything here.": "Ce que vos modèles retiennent de vous, d’une conversation à l’autre. Modifiez ou supprimez ce que vous voulez ici.",
"When": "Quand",
"When it matters": "Quand c'est important",
"Writing": "Écriture",
@@ -1772,6 +1804,7 @@ const LEMBAS_I18N = {
"About": "Névjegy",
"Account": "Fiók",
"Activity": "Tevékenység",
"Add": "Hozzáadás",
"Add folder…": "Mappa hozzáadása…",
"Address of your LLeMbas server": "A LLeMbas-kiszolgáló címe",
"Agent": "Ügynök",
@@ -1833,6 +1866,7 @@ const LEMBAS_I18N = {
"Counted by the model at the last reply; the categories are estimated.": "A modell számolta az utolsó válasznál; a kategóriák becslések.",
"Create": "Létrehozás",
"Cut short here; the whole of it is in the web interface.": "Itt rövidítve; a teljes a webes felületen van.",
"Data group": "Adatcsoport",
"Days": "Napok",
"Delete": "Törlés",
"Delete chat": "Csevegés törlése",
@@ -1895,6 +1929,7 @@ const LEMBAS_I18N = {
"Manual — asks first": "Manual — előbb kérdez",
"Max": "Max",
"Maximise": "Teljes méret",
"Memory": "Memória",
"Memory and personal": "Memória és személyes adatok",
"Menu": "Menü",
"Message": "Üzenet",
@@ -1929,10 +1964,12 @@ const LEMBAS_I18N = {
"Not measured.": "Nincs mérve.",
"Not run yet": "Még nem futott",
"Not yet": "Még nem",
"Noted by a model": "Egy modell jegyezte fel",
"Nothing has been run in this shell yet.": "Ebben a shellben még semmi sem futott.",
"Nothing has been said in the room yet.": "A szobában még senki nem mondott semmit.",
"Nothing is scheduled.": "Nincs semmi ütemezve.",
"Nothing matches.": "Nincs találat.",
"Nothing remembered yet.": "Még nincs megjegyezve semmi.",
"Nothing running.": "Semmi sem fut.",
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "A háttérben semmi sem fut. Az alügynökök, egy csapat tagjai és a háttérparancsok itt jelennek meg, amíg dolgoznak.",
"Nothing to report": "Nincs jelentenivaló",
@@ -1973,6 +2010,7 @@ const LEMBAS_I18N = {
"Regenerate reply": "Válasz újragenerálása",
"Remove": "Eltávolítás",
"Remove %(dir)s": "%(dir)s eltávolítása",
"Remove this memory?": "Eltávolítja ezt az emléket?",
"Rename": "Átnevezés",
"Rename chat": "Csevegés átnevezése",
"Rename folder": "Mappa átnevezése",
@@ -2016,6 +2054,7 @@ const LEMBAS_I18N = {
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Kijelentkezéskor az alkalmazás lemond a szerveren lévő bejelentkezéséről; az ügynök kapcsolata megmarad. Más szerver használatához jelentkezzen ki.",
"Since the last reply": "Az utolsó válasz óta",
"Skipped": "Kihagyva",
"Something to remember…": "Valami, amit meg kell jegyezni…",
"Source": "Forrás",
"Start the agent": "Ügynök indítása",
"Stop": "Leállítás",
@@ -2110,6 +2149,7 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Várakozás a(z) {{model}} modellre: {{reason}}",
"What it does": "Mit csinál",
"What should happen, and when": "Mi történjen, és mikor",
"What your models remember about you, across chats. Change or remove anything here.": "Amit a modelljei megjegyeznek Önről a beszélgetéseken át. Itt bármit módosíthat vagy eltávolíthat.",
"When": "Mikor",
"When it matters": "Ha számít",
"Writing": "Írás",
@@ -2204,6 +2244,7 @@ const LEMBAS_I18N = {
"About": "Informazioni",
"Account": "Account",
"Activity": "Attività",
"Add": "Aggiungi",
"Add folder…": "Aggiungi cartella…",
"Address of your LLeMbas server": "Indirizzo del tuo server LLeMbas",
"Agent": "Agente",
@@ -2265,6 +2306,7 @@ const LEMBAS_I18N = {
"Counted by the model at the last reply; the categories are estimated.": "Contato dal modello all’ultima risposta; le categorie sono stime.",
"Create": "Crea",
"Cut short here; the whole of it is in the web interface.": "Troncato qui; il resto è nell'interfaccia web.",
"Data group": "Gruppo di dati",
"Days": "Giorni",
"Delete": "Elimina",
"Delete chat": "Elimina chat",
@@ -2327,6 +2369,7 @@ const LEMBAS_I18N = {
"Manual — asks first": "Manual — chiede prima",
"Max": "Max",
"Maximise": "Ingrandisci",
"Memory": "Memoria",
"Memory and personal": "Memoria e dati personali",
"Menu": "Menu",
"Message": "Messaggio",
@@ -2361,10 +2404,12 @@ const LEMBAS_I18N = {
"Not measured.": "Non misurato.",
"Not run yet": "Non ancora eseguita",
"Not yet": "Non ancora",
"Noted by a model": "Annotato da un modello",
"Nothing has been run in this shell yet.": "In questa shell non è ancora stato eseguito nulla.",
"Nothing has been said in the room yet.": "Nella stanza non è ancora stato detto nulla.",
"Nothing is scheduled.": "Non c’è niente di pianificato.",
"Nothing matches.": "Nessun risultato.",
"Nothing remembered yet.": "Ancora niente da ricordare.",
"Nothing running.": "Nulla in esecuzione.",
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Nulla è in esecuzione in background. Sottoagenti, membri di una squadra e comandi in background compaiono qui mentre lavorano.",
"Nothing to report": "Niente da segnalare",
@@ -2405,6 +2450,7 @@ const LEMBAS_I18N = {
"Regenerate reply": "Rigenera risposta",
"Remove": "Rimuovi",
"Remove %(dir)s": "Rimuovi %(dir)s",
"Remove this memory?": "Rimuovere questo ricordo?",
"Rename": "Rinomina",
"Rename chat": "Rinomina chat",
"Rename folder": "Rinomina cartella",
@@ -2448,6 +2494,7 @@ const LEMBAS_I18N = {
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Uscendo, l'app rinuncia al suo accesso sul server; il collegamento dell'agente resta. Esca per usare un altro server.",
"Since the last reply": "Dall’ultima risposta",
"Skipped": "Saltata",
"Something to remember…": "Qualcosa da ricordare…",
"Source": "Sorgente",
"Start the agent": "Avvia l'agente",
"Stop": "Ferma",
@@ -2542,6 +2589,7 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "In attesa di {{model}}: {{reason}}",
"What it does": "Cosa fa",
"What should happen, and when": "Cosa deve succedere, e quando",
"What your models remember about you, across chats. Change or remove anything here.": "Ciò che i tuoi modelli ricordano di te tra una chat e l’altra. Qui puoi cambiare o rimuovere qualsiasi cosa.",
"When": "Quando",
"When it matters": "Quando conta",
"Writing": "Scrittura",
@@ -2636,6 +2684,7 @@ const LEMBAS_I18N = {
"About": "O aplikacji",
"Account": "Konto",
"Activity": "Aktywność",
"Add": "Dodaj",
"Add folder…": "Dodaj folder…",
"Address of your LLeMbas server": "Adres Twojego serwera LLeMbas",
"Agent": "Agent",
@@ -2697,6 +2746,7 @@ const LEMBAS_I18N = {
"Counted by the model at the last reply; the categories are estimated.": "Policzone przez model przy ostatniej odpowiedzi; kategorie są szacunkowe.",
"Create": "Utwórz",
"Cut short here; the whole of it is in the web interface.": "Tu skrócone; całość jest w interfejsie WWW.",
"Data group": "Grupa danych",
"Days": "Dni",
"Delete": "Usuń",
"Delete chat": "Usuń czat",
@@ -2759,6 +2809,7 @@ const LEMBAS_I18N = {
"Manual — asks first": "Manual — najpierw pyta",
"Max": "Max",
"Maximise": "Maksymalizuj",
"Memory": "Pamięć",
"Memory and personal": "Pamięć i dane osobiste",
"Menu": "Menu",
"Message": "Wiadomość",
@@ -2793,10 +2844,12 @@ const LEMBAS_I18N = {
"Not measured.": "Nie zmierzono.",
"Not run yet": "Jeszcze nie uruchomione",
"Not yet": "Jeszcze nie",
"Noted by a model": "Zanotowane przez model",
"Nothing has been run in this shell yet.": "W tej powłoce nic jeszcze nie uruchomiono.",
"Nothing has been said in the room yet.": "W pokoju nikt jeszcze nic nie powiedział.",
"Nothing is scheduled.": "Nic nie jest zaplanowane.",
"Nothing matches.": "Nic nie pasuje.",
"Nothing remembered yet.": "Na razie nic nie zapamiętano.",
"Nothing running.": "Nic nie działa.",
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "W tle nic nie działa. Podagenci, członkowie zespołu i polecenia w tle pojawiają się tu, gdy pracują.",
"Nothing to report": "Nic do zgłoszenia",
@@ -2837,6 +2890,7 @@ const LEMBAS_I18N = {
"Regenerate reply": "Wygeneruj odpowiedź ponownie",
"Remove": "Usuń",
"Remove %(dir)s": "Usuń %(dir)s",
"Remove this memory?": "Usunąć to wspomnienie?",
"Rename": "Zmień nazwę",
"Rename chat": "Zmień nazwę czatu",
"Rename folder": "Zmień nazwę folderu",
@@ -2880,6 +2934,7 @@ const LEMBAS_I18N = {
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Wylogowanie zrzeka się logowania aplikacji na serwerze; połączenie agenta zostaje. Wyloguj się, aby użyć innego serwera.",
"Since the last reply": "Od ostatniej odpowiedzi",
"Skipped": "Pominięty",
"Something to remember…": "Coś do zapamiętania…",
"Source": "Źródło",
"Start the agent": "Uruchom agenta",
"Stop": "Zatrzymać",
@@ -2974,6 +3029,7 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Czekam na {{model}}: {{reason}}",
"What it does": "Co robi",
"What should happen, and when": "Co ma się stać i kiedy",
"What your models remember about you, across chats. Change or remove anything here.": "Co Twoje modele pamiętają o Tobie we wszystkich czatach. Tutaj możesz wszystko zmienić lub usunąć.",
"When": "Kiedy",
"When it matters": "Gdy to ważne",
"Writing": "Pisanie",
@@ -3068,6 +3124,7 @@ const LEMBAS_I18N = {
"About": "O aplikácii",
"Account": "Účet",
"Activity": "Aktivita",
"Add": "Pridať",
"Add folder…": "Pridať priečinok…",
"Address of your LLeMbas server": "Adresa vášho servera LLeMbas",
"Agent": "Agent",
@@ -3129,6 +3186,7 @@ const LEMBAS_I18N = {
"Counted by the model at the last reply; the categories are estimated.": "Spočítal model pri poslednej odpovedi; kategórie sú odhadnuté.",
"Create": "Vytvoriť",
"Cut short here; the whole of it is in the web interface.": "Tu skrátené; celé je to vo webovom rozhraní.",
"Data group": "Dátová oblasť",
"Days": "Dni",
"Delete": "Zmazať",
"Delete chat": "Zmazať konverzáciu",
@@ -3191,6 +3249,7 @@ const LEMBAS_I18N = {
"Manual — asks first": "Manual — najprv sa opýta",
"Max": "Max",
"Maximise": "Maximalizovať",
"Memory": "Pamäť",
"Memory and personal": "Pamäť a osobné",
"Menu": "Ponuka",
"Message": "Správa",
@@ -3225,10 +3284,12 @@ const LEMBAS_I18N = {
"Not measured.": "Nezmerané.",
"Not run yet": "Ešte nebežala",
"Not yet": "Ešte nie",
"Noted by a model": "Zaznamenal model",
"Nothing has been run in this shell yet.": "V tomto shelli sa zatiaľ nič nespustilo.",
"Nothing has been said in the room yet.": "V miestnosti zatiaľ nikto nič nepovedal.",
"Nothing is scheduled.": "Nič nie je naplánované.",
"Nothing matches.": "Nič nezodpovedá.",
"Nothing remembered yet.": "Zatiaľ si nič nepamätá.",
"Nothing running.": "Nič nebeží.",
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Na pozadí nič nebeží. Podagenti, členovia tímu a príkazy na pozadí sa tu zobrazia, kým pracujú.",
"Nothing to report": "Nič na hlásenie",
@@ -3269,6 +3330,7 @@ const LEMBAS_I18N = {
"Regenerate reply": "Vygenerovať odpoveď znova",
"Remove": "Odstrániť",
"Remove %(dir)s": "Odstrániť %(dir)s",
"Remove this memory?": "Odstrániť tento záznam z pamäte?",
"Rename": "Premenovať",
"Rename chat": "Premenovať konverzáciu",
"Rename folder": "Premenovať zložku",
@@ -3312,6 +3374,7 @@ const LEMBAS_I18N = {
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlásením sa prihlásenie aplikácie na serveri zruší; prepojenie agenta zostane. Ak chcete použiť iný server, odhláste sa.",
"Since the last reply": "Od poslednej odpovede",
"Skipped": "Vynechané",
"Something to remember…": "Niečo na zapamätanie…",
"Source": "Zdroj",
"Start the agent": "Spustiť agenta",
"Stop": "Zastaviť",
@@ -3406,6 +3469,7 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Čaká sa na {{model}}: {{reason}}",
"What it does": "Čo robí",
"What should happen, and when": "Čo sa má stať a kedy",
"What your models remember about you, across chats. Change or remove anything here.": "Čo si o vás vaše modely pamätajú naprieč konverzáciami. Čokoľvek tu môžete zmeniť alebo odstrániť.",
"When": "Kedy",
"When it matters": "Keď na tom záleží",
"Writing": "Píše",
+10
View File
@@ -54,6 +54,7 @@
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
<button type="button" class="navrow" id="nav-archived" data-view="archived"><span class="navrow__icon"></span><span data-i18n>Archived</span></button>
<button type="button" class="navrow" id="nav-memory" data-view="memory"><span class="navrow__icon"></span><span data-i18n>Memory</span></button>
</div>
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
<div class="side__foot">
@@ -133,6 +134,15 @@
<div class="page__body" id="archived-body"></div>
</section>
<section class="page" id="page-memory" hidden aria-labelledby="memory-title">
<header class="head">
<h1 class="head__title" id="memory-title" data-i18n>Memory</h1>
<div class="head__place"></div>
<div class="head__tools"><button type="button" class="tool" id="memory-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button></div>
</header>
<div class="page__body" id="memory-body"></div>
</section>
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
<header class="head">
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
+20 -3
View File
@@ -10,7 +10,7 @@ import { renderBar, initBar } from "./titlebar.js"
import { renderSetup, initSetup } from "./setup.js"
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.js"
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
import { initExplorer, showLocal, showFilesTab, openServerFile } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js"
@@ -21,12 +21,13 @@ import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
import { initAgent, link } from "./agent.js"
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
import { initArchived, showArchived } from "./archived.js"
import { showMemory } from "./memory.js"
import { closePopover } from "./popup.js"
import { noteSubagentsCap } from "./models.js"
let status = null
let view = "" // "setup" | "app"
let page = "chat" // "chat" | "files" | "scheduled" | "archived" | "settings"
let page = "chat" // "chat" | "files" | "scheduled" | "archived" | "memory" | "settings"
let me = null
let models = []
let devices = [] // this account's linked devices
@@ -156,6 +157,7 @@ function showPage(name) {
$("page-files").hidden = name !== "files"
$("page-scheduled").hidden = name !== "scheduled"
$("page-archived").hidden = name !== "archived"
$("page-memory").hidden = name !== "memory"
$("settings").hidden = name !== "settings"
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
if (name !== "chat") {
@@ -208,6 +210,11 @@ function showArchivedPage() {
showArchived()
}
function showMemoryPage(focus) {
showPage("memory")
showMemory(focus || "")
}
function showFiles(which) {
showPage("files")
if (which) showFilesTab(which)
@@ -223,6 +230,7 @@ function commands() {
...scheduledCommands(),
{ label: t("Files"), run: () => showFiles() },
{ label: t("Archived"), run: showArchivedPage },
{ label: t("Memory"), run: () => showMemoryPage() },
{ label: t("Settings"), run: () => showSettings(true) },
]
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
@@ -301,7 +309,12 @@ async function boot() {
// Back in the sidebar: the list again.
changed: () => refreshList(),
})
initPalette({ commands, open: (c) => showChat(c.id) })
initPalette({
commands,
open: (c) => showChat(c.id),
openFile: (path, name) => openServerFile(path, name),
openMemory: (id) => showMemoryPage(id),
})
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
onViewer((on) => {
$("shell").dataset.viewer = String(on)
@@ -340,6 +353,10 @@ async function boot() {
$("nav-archived").addEventListener("click", showArchivedPage)
$("archived-refresh").replaceChildren(icon(ICONS.refresh))
$("archived-refresh").addEventListener("click", () => showArchived())
$("nav-memory").querySelector(".navrow__icon").replaceChildren(icon(ICONS.bulb))
$("nav-memory").addEventListener("click", () => showMemoryPage())
$("memory-refresh").replaceChildren(icon(ICONS.refresh))
$("memory-refresh").addEventListener("click", () => showMemory())
accountButton()
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
$("settings-close").replaceChildren(icon(ICONS.close))
+159
View File
@@ -0,0 +1,159 @@
// Memory: what the account's models remember about the person, as the web's Settings → Memory
// keeps it. Each record can be changed or removed; a new one is added at the top, into the data
// group chosen (where there is more than one).
//
// GET /memories { memories: [memory], groups: [{ id, name }] }
// POST /memories { content, data_group_id? } -> { memory }
// PATCH /memories/{id} { content } -> { memory }
// DELETE /memories/{id} 204
//
// A record may be a model's words (`author` other than "user"): set as text, never markup.
import { $, h, clear, icon, ICONS, when } from "./dom.js"
import { get, post, patch, del, why } from "./bridge.js"
import { ask, menu } from "./popup.js"
let rows = []
let groups = []
let error = ""
let loading = false
let editing = "" // the record being changed
let mark = "" // the record to bring into view (from a search hit)
/** The page, read again from the server; `focus` a record to bring into view. */
export async function showMemory(focus = "") {
mark = focus
loading = true
error = ""
render()
try {
const r = await get("/memories")
rows = r.memories || []
groups = r.groups || []
} catch (err) {
rows = []
error = why(err)
}
loading = false
render()
}
async function add(form) {
const text = form.querySelector("textarea")
const content = text.value.trim()
if (!content) return text.focus()
const group = form.querySelector("select")?.value
form.querySelector("button[type=submit]").disabled = true
try {
const r = await post("/memories", group ? { content, data_group_id: group } : { content })
if (r?.memory) rows = [r.memory, ...rows]
error = ""
text.value = ""
} catch (err) {
error = why(err)
}
render()
$("memory-new")?.focus()
}
async function save(m, content) {
content = content.trim()
if (!content || content === m.content) {
editing = ""
return render()
}
try {
const r = await patch(`/memories/${encodeURIComponent(m.id)}`, { content })
rows = rows.map((x) => (x.id === m.id ? r?.memory || { ...x, content } : x))
editing = ""
error = ""
} catch (err) {
error = why(err)
}
render()
}
async function remove(m) {
const yes = await ask({ title: t("Remove this memory?"), text: m.content, ok: t("Remove"), cancel: t("Cancel"), danger: true })
if (!yes) return
try {
await del(`/memories/${encodeURIComponent(m.id)}`)
rows = rows.filter((x) => x.id !== m.id)
error = ""
} catch (err) {
error = why(err)
}
render()
}
function row(m) {
const sub = [groups.length > 1 ? m.data_group?.name : "", m.author && m.author !== "user" ? t("Noted by a model") : "", when(m.updated_at || m.created_at)].filter(Boolean).join(" · ")
if (editing === m.id) {
const box = h("textarea", { class: "textarea mrow__edit", rows: "3", "aria-label": t("Memory") })
box.value = m.content
const done = () => save(m, box.value)
box.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.stopPropagation()
editing = ""
render()
}
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) done()
})
queueMicrotask(() => box.focus())
return h(
"li",
{ class: "mrow is-editing", dataset: { id: m.id } },
box,
h(
"div",
{ class: "set__buttons" },
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => ((editing = ""), render()) }),
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: done }),
),
)
}
return h(
"li",
{ class: `mrow${mark === m.id ? " is-marked" : ""}`, dataset: { id: m.id } },
h("div", { class: "mrow__words" }, h("p", { class: "mrow__text" }, h("bdi", { text: m.content })), h("p", { class: "mrow__sub", text: sub })),
h("button", { type: "button", class: "btn btn--sm", text: t("Edit"), onclick: () => ((editing = m.id), render()) }),
h(
"button",
{ type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => menu(e.currentTarget, [{ label: t("Remove"), danger: true, run: () => remove(m) }]) },
icon(ICONS.more),
),
)
}
function render() {
const box = clear($("memory-body"))
const parts = [h("p", { class: "muted", text: t("What your models remember about you, across chats. Change or remove anything here.") })]
const form = h(
"form",
{
class: "mnew",
onsubmit: (e) => {
e.preventDefault()
add(e.currentTarget)
},
},
h("textarea", { class: "textarea", id: "memory-new", rows: "2", placeholder: t("Something to remember…"), "aria-label": t("Something to remember…") }),
h(
"div",
{ class: "mnew__foot" },
groups.length > 1 ? h("select", { "aria-label": t("Data group") }, groups.map((g) => h("option", { value: g.id, text: g.name }))) : h("span"),
h("button", { type: "submit", class: "btn btn--sm btn--primary", text: t("Add") }),
),
)
form.querySelector("textarea").addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
})
parts.push(form)
if (error) parts.push(h("p", { class: "status is-error", role: "alert", text: error }))
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("Nothing remembered yet.") }))
if (rows.length) parts.push(h("ul", { class: "arch mlist", "aria-label": t("Memory") }, rows.map(row)))
box.append(h("div", { class: "arch__page" }, parts))
if (mark) box.querySelector(`.mrow[data-id="${CSS.escape(mark)}"]`)?.scrollIntoView({ block: "center" })
}
+70 -8
View File
@@ -1,12 +1,16 @@
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
// and the chats of both kinds by title, newest first. Up and Down move, Enter does it, Escape
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere
// is there.
// the chats of both kinds by title, newest first, and below them what the server's search finds
// inside them, in the file library and in memory (`GET /search`, once two letters are typed and
// the typing pauses). Up and Down move, Enter does it, Escape closes. The chats are asked of the
// server each time it opens, so a chat started elsewhere is there.
//
// A hit's snippet is plain text with the ranges that matched (`marks`): drawn as text, the
// ranges as <mark> elements. Titles and snippets may be a model's words; nothing is markup.
import { h, clear, icon, ICONS, when } from "./dom.js"
import { get } from "./bridge.js"
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
let ctx = null // { commands(): [{ label, hint?, run }], open(chat), openFile(path, name), openMemory(id) }
let dlg = null
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
@@ -17,6 +21,22 @@ function fold(text) {
.toLowerCase()
}
/** `text` with the `[start, end)` ranges of `marks` as <mark> elements: text nodes only. */
function marked(text, marks) {
const el = h("span", { class: "palette__snippet" })
let at = 0
const chars = [...String(text || "")]
for (const [a, b] of (marks || []).filter((m) => Array.isArray(m) && m[1] > m[0]).sort((x, y) => x[0] - y[0])) {
if (a < at) continue
el.append(chars.slice(at, a).join(""), h("mark", { text: chars.slice(a, b).join("") }))
at = b
}
el.append(chars.slice(at).join(""))
return el
}
const SEARCH_KIND = { chats: () => t("Chats"), files: () => t("Files"), memories: () => t("Memory") }
function matches(text, words) {
const f = fold(text)
return words.every((w) => f.includes(w))
@@ -31,7 +51,11 @@ export async function openPalette() {
let chats = []
let shown = []
let at = 0
let found = [] // the server's hits for `asked`
let asked = ""
let timer = 0
const close = () => {
clearTimeout(timer)
if (!dlg) return
dlg.close()
dlg.remove()
@@ -44,16 +68,32 @@ export async function openPalette() {
const draw = () => {
const words = fold(input.value).split(/\s+/).filter(Boolean)
const commands = ctx.commands().filter((c) => matches(c.label, words))
const found = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
const titled = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
shown = [
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
...found.map((c) => ({
...titled.map((c) => ({
kind: c.kind,
label: c.title || t("New chat"),
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
run: () => ctx.open(c),
})),
]
// What the server found inside: the chats already listed by their title once only.
const listed = new Set(titled.map((c) => c.id))
for (const group of input.value.trim() === asked ? found : []) {
for (const hit of group.hits || []) {
if (hit.kind === "chat" && listed.has(hit.id)) continue
const kind = { chat: "found-chat", file: "found-file", memory: "found-memory" }[hit.kind]
if (!kind) continue
shown.push({
kind,
label: hit.title || (hit.kind === "chat" ? t("New chat") : hit.kind === "file" ? String(hit.path || "").split("/").pop() : t("Memory")),
snippet: hit.snippet ? marked(hit.snippet, hit.marks) : null,
hint: SEARCH_KIND[group.kind]?.() || "",
run: () => (hit.kind === "chat" ? ctx.open({ id: hit.id, kind: hit.agent ? "agent" : "chat" }) : hit.kind === "file" ? ctx.openFile(hit.path, hit.title) : ctx.openMemory(hit.id)),
})
}
}
at = Math.min(at, Math.max(0, shown.length - 1))
clear(list)
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
@@ -74,8 +114,10 @@ export async function openPalette() {
}
},
},
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "agent" ? ICONS.folder : ICONS.file)),
h("span", { class: "palette__label", text: item.label }),
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "found-memory" ? ICONS.bulb : item.kind.startsWith("found") ? ICONS.search : item.kind === "agent" ? ICONS.folder : ICONS.file)),
item.snippet
? h("span", { class: "palette__label palette__label--two" }, h("span", { class: "palette__title", text: item.label }), item.snippet)
: h("span", { class: "palette__label", text: item.label }),
h("span", { class: "palette__hint", text: item.hint }),
),
)
@@ -87,9 +129,29 @@ export async function openPalette() {
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
}
// The server's search, once the typing pauses: an older server without it finds nothing more.
const search = () => {
clearTimeout(timer)
const q = input.value.trim()
if (q.length < 2) {
found = []
asked = ""
return
}
timer = setTimeout(async () => {
try {
const r = await get(`/search?q=${encodeURIComponent(q)}&in=all&limit=8`)
if (!dlg || input.value.trim() !== q) return
found = r.results || []
asked = q
draw()
} catch {}
}, 250)
}
input.addEventListener("input", () => {
at = 0
draw()
search()
})
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {