diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index c0b4ef6e235b..88292f8d5a76 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2191,6 +2191,7 @@ export const en: TranslationMap = { tablistLabel: "Memory sections", tabs: { overview: "Overview", + memories: "Memories", dreams: "Dreams", settings: "Settings", }, @@ -2237,10 +2238,32 @@ export const en: TranslationMap = { }, shortcuts: { title: "Explore memory", + memories: "Search memories", diary: "Read the dream diary", settings: "Configure memory", }, }, + memories: { + searchLabel: "Search memories", + searchPlaceholder: "Search this agent's memories", + searchButton: "Search", + idle: "Search for a person, project, decision, or anything else this agent remembers.", + searching: "Searching memories…", + results: "{count} results", + empty: "No memories matched “{query}”.", + error: "Memory search failed: {message}", + retry: "Retry", + gatewayUpdateRequired: "Update the gateway to search memories from the Control UI.", + hybridSearch: "hybrid search", + keywordSearch: "keyword search", + lineRange: "lines {start}–{end}", + score: "score {score}", + sourceMemory: "memory", + sourceSessions: "sessions", + fileLoading: "Loading the full memory file…", + fileError: "Could not load this memory file: {message}", + fileUnsupported: "This memory file cannot be shown as text.", + }, engine: { title: "Engine", description: diff --git a/ui/src/pages/config/memory-memories.test.ts b/ui/src/pages/config/memory-memories.test.ts new file mode 100644 index 000000000000..21706c21ddef --- /dev/null +++ b/ui/src/pages/config/memory-memories.test.ts @@ -0,0 +1,305 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { AgentSelectOption } from "../../components/agent-select.ts"; +import { waitForFast } from "../../test-helpers/wait-for.ts"; +import "./memory-memories.ts"; + +type Request = (method: string, params: Record) => Promise; +type MemoryMemoriesTestElement = HTMLElement & { + client: GatewayBrowserClient | null; + connected: boolean; + methodAdvertised: boolean; + agentId: string | null; + agents: readonly AgentSelectOption[]; + updateComplete: Promise; +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createElement(request: Request, advertised = true) { + const element = document.createElement("openclaw-memory-memories") as MemoryMemoriesTestElement; + element.client = { request } as unknown as GatewayBrowserClient; + element.connected = true; + element.methodAdvertised = advertised; + element.agentId = "main"; + element.agents = []; + document.body.append(element); + return element; +} + +async function typeQuery(element: MemoryMemoriesTestElement, query: string) { + await element.updateComplete; + const input = element.querySelector("#memory-search-input"); + if (!input) { + throw new Error("missing memory search input"); + } + input.value = query; + input.dispatchEvent(new InputEvent("input", { bubbles: true })); + await element.updateComplete; +} + +function submit(element: MemoryMemoriesTestElement) { + element + .querySelector("form") + ?.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); +} + +const result = { + path: "memory/people/ada.md", + startLine: 2, + endLine: 3, + score: 0.876, + snippet: "Ada prefers careful reviews.", + source: "memory" as const, +}; + +describe("MemoryMemoriesElement", () => { + it("renders idle and gateway-update-required states", async () => { + const current = createElement(vi.fn(() => Promise.resolve({}))); + await current.updateComplete; + expect(current.textContent).toContain("Search for a person, project, decision"); + expect(current.querySelector("form")).not.toBeNull(); + current.remove(); + + const old = createElement( + vi.fn(() => Promise.resolve({})), + false, + ); + await old.updateComplete; + expect(old.textContent).toContain("Update the gateway to search memories"); + expect(old.querySelector("form")).toBeNull(); + old.remove(); + }); + + it("keeps search disabled when no agent is available", async () => { + const request = vi.fn(() => Promise.resolve({})); + const element = createElement(request); + try { + element.agentId = null; + await typeQuery(element, "Ada"); + expect(element.querySelector('button[type="submit"]')?.disabled).toBe( + true, + ); + submit(element); + expect(request).not.toHaveBeenCalled(); + } finally { + element.remove(); + } + }); + + it("searches only on submit and renders loading, ready, mode, and result metadata", async () => { + const pending = deferred(); + const request = vi.fn(() => pending.promise); + const element = createElement(request); + try { + await typeQuery(element, "Ada"); + expect(request).not.toHaveBeenCalled(); + + submit(element); + await waitForFast(() => expect(element.textContent).toContain("Searching memories")); + expect(request).toHaveBeenCalledWith("memory.search", { query: "Ada", agentId: "main" }); + + pending.resolve({ + agentId: "main", + provider: "local", + searchMode: "hybrid", + results: [result], + }); + await waitForFast(() => expect(element.textContent).toContain(result.snippet)); + expect(element.textContent).toContain("hybrid search"); + expect(element.textContent?.replace(/\s+/g, " ")).toContain( + "memory/people/ada.md · lines 2–3", + ); + expect(element.textContent).toContain("score 0.88"); + expect(element.textContent).toContain("memory"); + } finally { + element.remove(); + } + }); + + it("renders empty and retryable error states", async () => { + const request = vi + .fn() + .mockRejectedValueOnce(new Error("index unavailable")) + .mockResolvedValueOnce({ + agentId: "main", + provider: "none", + searchMode: "fts-only", + results: [], + }); + const element = createElement(request); + try { + await typeQuery(element, "missing"); + submit(element); + await waitForFast(() => expect(element.textContent).toContain("index unavailable")); + + const retry = [...element.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Retry", + ); + retry?.click(); + await waitForFast(() => expect(element.textContent).toContain("No memories matched")); + expect(element.textContent).toContain("keyword search"); + expect(request).toHaveBeenCalledTimes(2); + } finally { + element.remove(); + } + }); + + it("loads a row file once, highlights the matched range, and keeps one row open", async () => { + const second = { ...result, path: "memory/projects/Open Claw.md", startLine: 1, endLine: 1 }; + const request = vi.fn((method: string) => { + if (method === "memory.search") { + return Promise.resolve({ + agentId: "main", + provider: "local", + searchMode: "hybrid", + results: [result, second], + }); + } + return Promise.resolve({ + agentId: "main", + file: { + path: result.path, + name: "ada.md", + size: 30, + updatedAtMs: 1, + mimeType: "text/plain", + encoding: "utf8", + content: "first\nmatched two\nmatched three\nfourth", + }, + }); + }); + const element = createElement(request); + try { + await typeQuery(element, "Ada"); + submit(element); + await waitForFast(() => expect(element.querySelectorAll("article")).toHaveLength(2)); + + const rows = element.querySelectorAll("article > button"); + rows[0]?.click(); + await waitForFast(() => + expect(element.querySelector('[data-memory-match="true"]')).toBeTruthy(), + ); + expect(element.querySelector('[data-memory-match="true"]')?.textContent).toBe( + "matched two\nmatched three", + ); + + rows[0]?.click(); + rows[0]?.click(); + await element.updateComplete; + expect( + request.mock.calls.filter(([method]) => method === "agents.workspace.get"), + ).toHaveLength(1); + expect(request).toHaveBeenCalledWith("agents.workspace.get", { + agentId: "main", + path: result.path, + }); + + rows[1]?.click(); + await element.updateComplete; + expect(rows[0]?.getAttribute("aria-expanded")).toBe("false"); + expect(rows[1]?.getAttribute("aria-expanded")).toBe("true"); + expect(rows[1]?.getAttribute("aria-controls")).toBe("memory-detail-1"); + expect(element.querySelector("#memory-detail-1")).not.toBeNull(); + } finally { + element.remove(); + } + }); + + it("keeps session, QMD, absolute, and escaping paths non-expandable", async () => { + const nonExpandable = [ + { ...result, path: "sessions/main/session-1.jsonl", source: "sessions" as const }, + { ...result, path: "sessions/main/mislabeled.jsonl" }, + { ...result, path: "qmd/workspace-main/memory/notes.md" }, + { ...result, path: "/external/MEMORY.md" }, + { ...result, path: "C:\\external\\MEMORY.md" }, + { ...result, path: "memory/../outside.md" }, + ]; + const request = vi.fn(() => + Promise.resolve({ + agentId: "main", + provider: "local", + searchMode: "hybrid", + results: [{ ...result, path: "MEMORY.md" }, ...nonExpandable], + }), + ); + const element = createElement(request); + try { + await typeQuery(element, "memory"); + submit(element); + await waitForFast(() => expect(element.querySelectorAll("article")).toHaveLength(7)); + + expect(element.querySelectorAll("article > button")).toHaveLength(1); + expect(element.querySelectorAll("article > div.settings-row")).toHaveLength(6); + expect( + request.mock.calls.filter(([method]) => method === "agents.workspace.get"), + ).toHaveLength(0); + } finally { + element.remove(); + } + }); + + it("keeps workspace read failures on the expanded row", async () => { + const request = vi.fn((method: string) => + method === "memory.search" + ? Promise.resolve({ + agentId: "main", + provider: "local", + searchMode: "hybrid", + results: [result], + }) + : Promise.reject(new Error("workspace file not found")), + ); + const element = createElement(request); + try { + await typeQuery(element, "Ada"); + submit(element); + await waitForFast(() => expect(element.querySelector("article > button")).not.toBeNull()); + element.querySelector("article > button")?.click(); + + await waitForFast(() => + expect(element.textContent).toContain( + "Could not load this memory file: workspace file not found", + ), + ); + expect(element.textContent).toContain(result.snippet); + expect(element.textContent).not.toContain("Memory search failed"); + } finally { + element.remove(); + } + }); + + it("resets results when the selected agent changes", async () => { + const request = vi.fn(() => + Promise.resolve({ + agentId: "main", + provider: "local", + searchMode: "hybrid", + results: [result], + }), + ); + const element = createElement(request); + try { + await typeQuery(element, "Ada"); + submit(element); + await waitForFast(() => expect(element.textContent).toContain(result.snippet)); + + element.agentId = "research"; + await waitForFast(() => expect(element.textContent).toContain("Search for a person")); + expect(element.textContent).not.toContain(result.snippet); + expect(element.querySelector("#memory-search-input")?.value).toBe(""); + } finally { + element.remove(); + } + }); +}); diff --git a/ui/src/pages/config/memory-memories.ts b/ui/src/pages/config/memory-memories.ts new file mode 100644 index 000000000000..fa506b9c961f --- /dev/null +++ b/ui/src/pages/config/memory-memories.ts @@ -0,0 +1,328 @@ +import { html, nothing, type PropertyValues } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { AgentsWorkspaceGetResult } from "../../../../packages/gateway-protocol/src/index.js"; +import type { MemorySearchResponse } from "../../../../src/gateway/server-methods/memory-search.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { AgentSelectOption } from "../../components/agent-select.ts"; +import { t } from "../../i18n/index.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import "../../styles/memory-memories.css"; +import { renderMemoryAgentScope } from "./memory.ts"; + +type SearchResult = MemorySearchResponse["results"][number]; +type SearchState = + | { kind: "idle" } + | { kind: "loading"; query: string } + | ({ kind: "ready"; query: string } & MemorySearchResponse) + | { kind: "error"; query: string; message: string }; +type DetailState = + | { kind: "loading" } + | { kind: "ready"; content: string } + | { kind: "error"; message: string }; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function resultKey(result: SearchResult, index: number): string { + return `${index}:${result.path}:${result.startLine}:${result.endLine}`; +} + +function isExpandableWorkspaceResult(result: SearchResult): boolean { + const normalizedPath = result.path.replaceAll("\\", "/"); + const safeRelativePath = + !normalizedPath.startsWith("/") && + !normalizedPath.startsWith("sessions/") && + !/^[a-zA-Z]:\//.test(normalizedPath) && + normalizedPath.split("/").every((segment) => segment && segment !== "." && segment !== ".."); + const workspaceMemoryPath = + normalizedPath === "MEMORY.md" || normalizedPath.startsWith("memory/"); + // workspace.get is workspace-contained; sessions/* and qmd/* are logical manager paths. + return result.source === "memory" && safeRelativePath && workspaceMemoryPath; +} + +function renderFileContent(content: string, result: SearchResult) { + const lines = content.split(/\r?\n/); + const start = Math.max(0, result.startLine - 1); + const end = Math.min(lines.length, result.endLine); + const before = lines.slice(0, start); + const matched = lines.slice(start, end); + const after = lines.slice(end); + return html`
${before.join("\n")}${before.length ? "\n" : ""}${matched.join("\n")}${after.length ? `\n${after.join("\n")}` : ""}
`; +} + +class MemoryMemoriesElement extends OpenClawLightDomElement { + @property({ attribute: false }) client: GatewayBrowserClient | null = null; + @property({ type: Boolean }) connected = false; + @property({ type: Boolean }) methodAdvertised = true; + @property() agentId: string | null = null; + @property({ attribute: false }) agents: readonly AgentSelectOption[] = []; + @property({ attribute: false }) onAgentChange: (agentId: string | null) => void = () => {}; + + @state() private query = ""; + @state() private searchState: SearchState = { kind: "idle" }; + @state() private openResultKey: string | null = null; + @state() private details = new Map(); + + private searchRequest: object | null = null; + private detailRequests = new Map(); + + protected override updated(changed: PropertyValues) { + if ( + changed.has("agentId") || + changed.has("client") || + changed.has("connected") || + changed.has("methodAdvertised") + ) { + this.resetSearch(); + } + } + + private resetSearch() { + this.searchRequest = null; + this.detailRequests.clear(); + this.query = ""; + this.searchState = { kind: "idle" }; + this.openResultKey = null; + this.details = new Map(); + } + + private async search(query: string) { + const normalizedQuery = query.trim(); + const client = this.connected ? this.client : null; + const agentId = this.agentId; + if (!normalizedQuery || !client || !agentId || !this.methodAdvertised) { + return; + } + const request = { client, agentId, query: normalizedQuery }; + this.searchRequest = request; + this.query = normalizedQuery; + this.searchState = { kind: "loading", query: normalizedQuery }; + this.openResultKey = null; + this.details = new Map(); + this.detailRequests.clear(); + try { + const result = await client.request("memory.search", { + query: normalizedQuery, + agentId, + }); + if (this.searchRequest !== request || this.agentId !== agentId || this.client !== client) { + return; + } + this.searchState = { kind: "ready", query: normalizedQuery, ...result }; + } catch (error) { + if (this.searchRequest !== request || this.agentId !== agentId || this.client !== client) { + return; + } + this.searchState = { kind: "error", query: normalizedQuery, message: errorMessage(error) }; + } + } + + private toggleResult(result: SearchResult, index: number) { + const key = resultKey(result, index); + if (this.openResultKey === key) { + this.openResultKey = null; + return; + } + this.openResultKey = key; + if (!this.details.has(key)) { + void this.loadDetail(key, result); + } + } + + private async loadDetail(key: string, result: SearchResult) { + const client = this.connected ? this.client : null; + const agentId = this.agentId; + if (!client || !agentId) { + return; + } + const request = { client, agentId, path: result.path }; + this.detailRequests.set(key, request); + this.details = new Map(this.details).set(key, { kind: "loading" }); + try { + const response = await client.request("agents.workspace.get", { + agentId, + path: result.path, + }); + if (this.detailRequests.get(key) !== request || this.agentId !== agentId) { + return; + } + const detail: DetailState = + response.file.encoding === "utf8" + ? { kind: "ready", content: response.file.content } + : { kind: "error", message: t("memoryPage.memories.fileUnsupported") }; + this.details = new Map(this.details).set(key, detail); + } catch (error) { + if (this.detailRequests.get(key) !== request || this.agentId !== agentId) { + return; + } + this.details = new Map(this.details).set(key, { + kind: "error", + message: errorMessage(error), + }); + } finally { + if (this.detailRequests.get(key) === request) { + this.detailRequests.delete(key); + } + } + } + + private renderDetail(key: string, panelId: string, result: SearchResult) { + if (this.openResultKey !== key) { + return nothing; + } + const detail = this.details.get(key); + return html`
+ ${!detail || detail.kind === "loading" + ? html`

${t("memoryPage.memories.fileLoading")}

` + : detail.kind === "error" + ? html`` + : renderFileContent(detail.content, result)} +
`; + } + + private renderResults(ready: Extract) { + const mode = + ready.searchMode === "hybrid" + ? t("memoryPage.memories.hybridSearch") + : t("memoryPage.memories.keywordSearch"); + return html` +
+ ${t("memoryPage.memories.results", { count: String(ready.results.length) })} + ${mode} +
+ ${ready.results.length === 0 + ? html`

+ ${t("memoryPage.memories.empty", { + query: ready.query, + })} +

` + : html`
+ ${ready.results.map((result, index) => { + const key = resultKey(result, index); + const open = this.openResultKey === key; + const expandable = isExpandableWorkspaceResult(result); + const panelId = `memory-detail-${index}`; + const summary = html` + + ${result.snippet} + ${result.path} · + ${t("memoryPage.memories.lineRange", { + start: String(result.startLine), + end: String(result.endLine), + })} + + + ${t( + result.source === "sessions" + ? "memoryPage.memories.sourceSessions" + : "memoryPage.memories.sourceMemory", + )} + ${t("memoryPage.memories.score", { + score: result.score.toFixed(2), + })} + + `; + return html`
+ ${expandable + ? html`` + : html`
${summary}
`} + ${expandable ? this.renderDetail(key, panelId, result) : nothing} +
`; + })} +
`} + `; + } + + private renderSearchState() { + switch (this.searchState.kind) { + case "loading": + return html`

+ ${t("memoryPage.memories.searching")} +

`; + case "error": { + const failed = this.searchState; + return html``; + } + case "ready": + return this.renderResults(this.searchState); + default: + return html`

${t("memoryPage.memories.idle")}

`; + } + } + + override render() { + return html`
+ ${renderMemoryAgentScope({ + agentId: this.agentId, + agents: this.agents, + onAgentChange: this.onAgentChange, + })} + ${!this.methodAdvertised + ? html`

+ ${t("memoryPage.memories.gatewayUpdateRequired")} +

` + : html` + ${this.renderSearchState()}`} +
`; + } +} + +if (!customElements.get("openclaw-memory-memories")) { + customElements.define("openclaw-memory-memories", MemoryMemoriesElement); +} diff --git a/ui/src/pages/config/memory-overview.test.ts b/ui/src/pages/config/memory-overview.test.ts index 4b7df45356d0..b1aed44e3f53 100644 --- a/ui/src/pages/config/memory-overview.test.ts +++ b/ui/src/pages/config/memory-overview.test.ts @@ -1,8 +1,25 @@ /* @vitest-environment jsdom */ +import { expectDefined } from "@openclaw/normalization-core"; import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-methods/doctor.ts"; + +// The hero derives its lobster from lobsterPetSeed, which mixes in a random +// per-load salt, so the palette (and with it sprite geometry like the sleeping +// eye peek) varies per test process. Pin a canonical look so pose assertions +// stay deterministic. +vi.mock("../../components/lobster-pet.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createLobsterPetLook: () => + actual.canonicalLobsterLook( + expectDefined(actual.LOBSTER_PET_PALETTES[0], "canonical lobster palette"), + ), + }; +}); + import { renderMemoryOverview, type MemoryOverviewStatus } from "./memory-overview.ts"; function fixturePayload(): DoctorMemoryStatusPayload { @@ -167,4 +184,28 @@ describe("renderMemoryOverview", () => { expect(lightRow?.textContent).not.toContain("Enabled"); expect(lightRow?.textContent).not.toContain("next "); }); + + it("opens the Memories tab from the overview shortcut", () => { + const onNavigate = vi.fn(); + const container = document.createElement("div"); + render( + renderMemoryOverview({ + agentId: "main", + agents: [], + engineSelection: { kind: "auto", engineId: "memory-core" }, + engineDisabled: false, + status: { kind: "ready", payload: fixturePayload() }, + onAgentChange: vi.fn(), + onRefresh: vi.fn(), + onNavigate, + }), + container, + ); + + const shortcut = [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("Search memories"), + ); + shortcut?.click(); + expect(onNavigate).toHaveBeenCalledWith("memories"); + }); }); diff --git a/ui/src/pages/config/memory-overview.ts b/ui/src/pages/config/memory-overview.ts index e7e052d6139b..321d3b3169f2 100644 --- a/ui/src/pages/config/memory-overview.ts +++ b/ui/src/pages/config/memory-overview.ts @@ -34,7 +34,7 @@ type MemoryOverviewProps = { status: MemoryOverviewStatus; onAgentChange: (agentId: string | null) => void; onRefresh: () => void; - onNavigate: (tab: "dreams" | "settings") => void; + onNavigate: (tab: "memories" | "dreams" | "settings") => void; }; type DreamingStatus = NonNullable; @@ -247,6 +247,10 @@ function renderShortcuts(props: MemoryOverviewProps) { return renderSettingsSection( { title: t("memoryPage.overview.shortcuts.title") }, html` + ${renderSettingsNavRow({ + title: t("memoryPage.overview.shortcuts.memories"), + onClick: () => props.onNavigate("memories"), + })} ${renderSettingsNavRow({ title: t("memoryPage.overview.shortcuts.diary"), onClick: () => props.onNavigate("dreams"), diff --git a/ui/src/pages/config/memory-page.test.ts b/ui/src/pages/config/memory-page.test.ts index 2e65dc0e3ee7..21338cb9fc73 100644 --- a/ui/src/pages/config/memory-page.test.ts +++ b/ui/src/pages/config/memory-page.test.ts @@ -137,7 +137,7 @@ function addonStatus(element: HTMLElement, label: string): string | null { } /** Which tab body is actually mounted, rather than what the tab strip claims. */ -function visibleTab(element: HTMLElement): "overview" | "dreams" | "settings" | null { +function visibleTab(element: HTMLElement): "overview" | "memories" | "dreams" | "settings" | null { const panel = element.querySelector('[role="tabpanel"]'); if (!panel) { return null; @@ -145,6 +145,9 @@ function visibleTab(element: HTMLElement): "overview" | "dreams" | "settings" | if (panel.querySelector("openclaw-memory-dreaming")) { return "dreams"; } + if (panel.querySelector("openclaw-memory-memories")) { + return "memories"; + } return panel.querySelector(".memory-overview") ? "overview" : "settings"; } @@ -373,6 +376,10 @@ describe("MemorySettingsPage tab routing", () => { element.tab = "settings"; await element.updateComplete; expect(visibleTab(element)).toBe("settings"); + + element.tab = "memories"; + await element.updateComplete; + expect(visibleTab(element)).toBe("memories"); } finally { element.remove(); } @@ -392,6 +399,9 @@ describe("MemorySettingsPage tab routing", () => { // Nothing moves until the router feeds the new tab back in. await element.updateComplete; expect(visibleTab(element)).toBe("overview"); + + selectTab(element, "memories"); + expect(navigate).toHaveBeenCalledWith("memory", { search: "?tab=memories" }); } finally { element.remove(); } diff --git a/ui/src/pages/config/memory-page.ts b/ui/src/pages/config/memory-page.ts index 6717179e1f0c..4f33d818a387 100644 --- a/ui/src/pages/config/memory-page.ts +++ b/ui/src/pages/config/memory-page.ts @@ -11,6 +11,7 @@ import type { AgentSelectOption } from "../../components/agent-select.ts"; import { t } from "../../i18n/index.ts"; import { listSelectableAgents, normalizeAgentLabel } from "../../lib/agents/display.ts"; import { currentConfigObject } from "../../lib/config/index.ts"; +import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; import { loadPluginCatalog, setPluginEnabled, @@ -24,6 +25,7 @@ import { type DreamingConfigPathSupport, } from "../agents/memory/dreaming.ts"; import "./memory-dreaming-page.ts"; +import "./memory-memories.ts"; import { renderDreamingSettings, renderDreamingUnsupported } from "./memory-dreaming.ts"; import { renderMemoryOverview, type MemoryOverviewStatus } from "./memory-overview.ts"; import { @@ -461,6 +463,19 @@ class MemorySettingsPage extends OpenClawLightDomElement { onRefresh: () => void this.loadOverviewStatus(true), onNavigate: (tab) => this.navigateTab(tab), }), + memories: html` + this.selectAgent(next)} + > + `, dreams: html` = {}): MemoryViewProps pluginsHref: "/settings/plugins", memoryImportHref: "/memory-import", overview: html`
`, + memories: html`
`, dreams: html`
`, editor: html`
`, dreamingSettings: html`
`, @@ -133,6 +134,9 @@ describe("renderMemory", () => { expect( renderInto(createProps({ activeTab: "overview" })).querySelector(".test-overview"), ).not.toBeNull(); + expect( + renderInto(createProps({ activeTab: "memories" })).querySelector(".test-memories"), + ).not.toBeNull(); const settings = renderInto(createProps({ activeTab: "settings" })); expect(settings.querySelector(".test-editor")).not.toBeNull(); @@ -149,6 +153,7 @@ describe("memoryTabForRoute", () => { expect(memoryTabForRoute({ tab: "search" })).toBe("settings"); expect(memoryTabForRoute({ tab: "dreaming" })).toBe("dreams"); expect(memoryTabForRoute({ tab: "overview" })).toBe("overview"); + expect(memoryTabForRoute({ tab: "memories" })).toBe("memories"); expect(memoryTabForRoute({ tab: "unknown" })).toBeNull(); }); @@ -164,6 +169,7 @@ describe("memoryTabForRoute", () => { describe("memorySchemaKeysForTab", () => { it("reveals qmd sub-config only when qmd is the selected backend", () => { expect(memorySchemaKeysForTab("overview", "builtin")).toEqual([]); + expect(memorySchemaKeysForTab("memories", "builtin")).toEqual([]); expect(memorySchemaKeysForTab("dreams", "qmd")).toEqual([]); expect(memorySchemaKeysForTab("settings", "builtin")).toEqual(["citations", "search"]); expect(memorySchemaKeysForTab("settings", "qmd")).toEqual(["citations", "qmd", "search"]); diff --git a/ui/src/pages/config/memory.ts b/ui/src/pages/config/memory.ts index ad64b4a66db5..cd281e0e602a 100644 --- a/ui/src/pages/config/memory.ts +++ b/ui/src/pages/config/memory.ts @@ -64,6 +64,8 @@ type MemoryViewProps = { memoryImportHref: string; /** New status-led landing view. */ overview: TemplateResult; + /** Search and read the selected agent's indexed memory. */ + memories: TemplateResult; /** Agent-scoped dream diary and scene. */ dreams: TemplateResult; /** One embedded editor for every `memory.*` schema field. */ @@ -284,6 +286,7 @@ export function renderMemory(props: MemoryViewProps) { active: props.activeTab, tabs: [ { value: "overview", label: t("memoryPage.tabs.overview") }, + { value: "memories", label: t("memoryPage.tabs.memories") }, { value: "dreams", label: t("memoryPage.tabs.dreams") }, { value: "settings", label: t("memoryPage.tabs.settings") }, ], @@ -294,9 +297,11 @@ export function renderMemory(props: MemoryViewProps) {
${props.activeTab === "overview" ? props.overview - : props.activeTab === "dreams" - ? props.dreams - : renderSettingsTab(props)} + : props.activeTab === "memories" + ? props.memories + : props.activeTab === "dreams" + ? props.dreams + : renderSettingsTab(props)}
`; diff --git a/ui/src/styles/memory-memories.css b/ui/src/styles/memory-memories.css new file mode 100644 index 000000000000..8e6c07afef76 --- /dev/null +++ b/ui/src/styles/memory-memories.css @@ -0,0 +1,92 @@ +.memory-memories__search { + display: flex; + gap: var(--space-2); +} + +.memory-memories__search .settings-input { + flex: 1; +} + +.memory-memories__state, +.memory-memories__unavailable { + margin: 0; + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--card); + color: var(--muted); +} + +.memory-memories__state p, +.memory-memories__detail p { + margin: 0 0 var(--space-3); +} + +.memory-memories__results-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + color: var(--text); + font-size: var(--control-ui-text-sm); + font-weight: 600; +} + +.memory-memories__mode, +.memory-memories__source { + padding: 2px 7px; + border-radius: 999px; + background: var(--bg-accent); + color: var(--muted); + font-size: var(--control-ui-text-xs); + font-weight: 600; +} + +.memory-memories__result + .memory-memories__result { + border-top: 1px solid var(--border); +} + +.memory-memories__meta { + flex-wrap: wrap; + color: var(--muted); + font-size: var(--control-ui-text-xs); +} + +.memory-memories__path { + overflow-wrap: anywhere; + font-family: var(--mono); +} + +.memory-memories__detail { + padding: 0 var(--space-4) var(--space-4); +} + +.memory-memories__detail-error { + margin: 0; + color: var(--danger); + font-size: var(--control-ui-text-sm); +} + +.memory-memories__file { + max-height: 420px; + margin: 0; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); + color: var(--text); + font: 12px/1.55 var(--mono); + white-space: pre; +} + +.memory-memories__file mark { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: inherit; +} + +@media (max-width: 560px) { + .memory-memories__search { + align-items: stretch; + flex-direction: column; + } +}