Files
LLeMbas-Desktop/ui/js/memory.js
T
HomerandClaude Opus 5.5 d691a4dcfa
ci / ui (push) Waiting to run
ci / check (push) Failing after 7s
ci / start (push) Failing after 5s
Memory page and search inside chats, files and memory (Ctrl+K, GET /search)
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>
2026-11-10 13:44:49 +00:00

160 lines
5.7 KiB
JavaScript

// 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" })
}