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>
This commit is contained in:
9 files changed
+515
-11
No files matched your search
+20
-3
@@ -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
@@ -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
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user