mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 21:11:40 +00:00
feat(ui): add Memories search tab to Memory settings (#115419)
* feat(ui): add Memories search tab to Memory settings * test(ui): pin a canonical lobster look in memory overview tests lobsterPetSeed mixes a random per-load salt into the seed, so the hero's palette—and palette-dependent sprite geometry like the sleeping eye peek—varied per test process and failed ~1 in 3 runs. * test(ui): satisfy memory overview palette typing
This commit is contained in:
committed by
GitHub
parent
74f3af9735
commit
d74e2214d3
@@ -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:
|
||||
|
||||
305
ui/src/pages/config/memory-memories.test.ts
Normal file
305
ui/src/pages/config/memory-memories.test.ts
Normal file
@@ -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<string, unknown>) => Promise<unknown>;
|
||||
type MemoryMemoriesTestElement = HTMLElement & {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
methodAdvertised: boolean;
|
||||
agentId: string | null;
|
||||
agents: readonly AgentSelectOption[];
|
||||
updateComplete: Promise<unknown>;
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((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<HTMLInputElement>("#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<HTMLButtonElement>('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<unknown>();
|
||||
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<Request>()
|
||||
.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<HTMLButtonElement>("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<Request>(() =>
|
||||
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<HTMLButtonElement>("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<HTMLInputElement>("#memory-search-input")?.value).toBe("");
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
328
ui/src/pages/config/memory-memories.ts
Normal file
328
ui/src/pages/config/memory-memories.ts
Normal file
@@ -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`<pre class="memory-memories__file" tabindex="0"><span
|
||||
>${before.join("\n")}${before.length ? "\n" : ""}</span
|
||||
><mark data-memory-match="true">${matched.join("\n")}</mark
|
||||
><span>${after.length ? `\n${after.join("\n")}` : ""}</span></pre>`;
|
||||
}
|
||||
|
||||
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<string, DetailState>();
|
||||
|
||||
private searchRequest: object | null = null;
|
||||
private detailRequests = new Map<string, object>();
|
||||
|
||||
protected override updated(changed: PropertyValues<this>) {
|
||||
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<MemorySearchResponse>("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<AgentsWorkspaceGetResult>("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`<div id=${panelId} class="memory-memories__detail">
|
||||
${!detail || detail.kind === "loading"
|
||||
? html`<p role="status">${t("memoryPage.memories.fileLoading")}</p>`
|
||||
: detail.kind === "error"
|
||||
? html`<p class="memory-memories__detail-error" role="alert">
|
||||
${t("memoryPage.memories.fileError", { message: detail.message })}
|
||||
</p>`
|
||||
: renderFileContent(detail.content, result)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderResults(ready: Extract<SearchState, { kind: "ready" }>) {
|
||||
const mode =
|
||||
ready.searchMode === "hybrid"
|
||||
? t("memoryPage.memories.hybridSearch")
|
||||
: t("memoryPage.memories.keywordSearch");
|
||||
return html`
|
||||
<div class="memory-memories__results-heading">
|
||||
<span>${t("memoryPage.memories.results", { count: String(ready.results.length) })}</span>
|
||||
<span class="memory-memories__mode">${mode}</span>
|
||||
</div>
|
||||
${ready.results.length === 0
|
||||
? html`<p class="memory-memories__state">
|
||||
${t("memoryPage.memories.empty", {
|
||||
query: ready.query,
|
||||
})}
|
||||
</p>`
|
||||
: html`<div class="settings-group memory-memories__results">
|
||||
${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`
|
||||
<span class="settings-row__text">
|
||||
<span class="settings-row__title">${result.snippet}</span>
|
||||
<span class="settings-row__desc memory-memories__path"
|
||||
>${result.path} ·
|
||||
${t("memoryPage.memories.lineRange", {
|
||||
start: String(result.startLine),
|
||||
end: String(result.endLine),
|
||||
})}</span
|
||||
>
|
||||
</span>
|
||||
<span class="settings-row__control memory-memories__meta">
|
||||
<span class="memory-memories__source"
|
||||
>${t(
|
||||
result.source === "sessions"
|
||||
? "memoryPage.memories.sourceSessions"
|
||||
: "memoryPage.memories.sourceMemory",
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
>${t("memoryPage.memories.score", {
|
||||
score: result.score.toFixed(2),
|
||||
})}</span
|
||||
>
|
||||
</span>
|
||||
`;
|
||||
return html`<article class="memory-memories__result">
|
||||
${expandable
|
||||
? html`<button
|
||||
type="button"
|
||||
class="settings-row settings-row--nav"
|
||||
aria-expanded=${String(open)}
|
||||
aria-controls=${panelId}
|
||||
@click=${() => this.toggleResult(result, index)}
|
||||
>
|
||||
${summary}
|
||||
</button>`
|
||||
: html`<div class="settings-row">${summary}</div>`}
|
||||
${expandable ? this.renderDetail(key, panelId, result) : nothing}
|
||||
</article>`;
|
||||
})}
|
||||
</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSearchState() {
|
||||
switch (this.searchState.kind) {
|
||||
case "loading":
|
||||
return html`<p class="memory-memories__state" role="status">
|
||||
${t("memoryPage.memories.searching")}
|
||||
</p>`;
|
||||
case "error": {
|
||||
const failed = this.searchState;
|
||||
return html`<div class="memory-memories__state" role="alert">
|
||||
<p>${t("memoryPage.memories.error", { message: failed.message })}</p>
|
||||
<button class="btn btn--sm" @click=${() => void this.search(failed.query)}>
|
||||
${t("memoryPage.memories.retry")}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
case "ready":
|
||||
return this.renderResults(this.searchState);
|
||||
default:
|
||||
return html`<p class="memory-memories__state">${t("memoryPage.memories.idle")}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class="settings-page memory-memories">
|
||||
${renderMemoryAgentScope({
|
||||
agentId: this.agentId,
|
||||
agents: this.agents,
|
||||
onAgentChange: this.onAgentChange,
|
||||
})}
|
||||
${!this.methodAdvertised
|
||||
? html`<p class="memory-memories__unavailable">
|
||||
${t("memoryPage.memories.gatewayUpdateRequired")}
|
||||
</p>`
|
||||
: html`<form
|
||||
class="memory-memories__search"
|
||||
role="search"
|
||||
@submit=${(event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
void this.search(this.query);
|
||||
}}
|
||||
>
|
||||
<label class="settings-control__sr-label" for="memory-search-input"
|
||||
>${t("memoryPage.memories.searchLabel")}</label
|
||||
>
|
||||
<input
|
||||
id="memory-search-input"
|
||||
type="search"
|
||||
class="settings-input"
|
||||
.value=${this.query}
|
||||
placeholder=${t("memoryPage.memories.searchPlaceholder")}
|
||||
@input=${(event: InputEvent) => {
|
||||
this.query = (event.currentTarget as HTMLInputElement).value;
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
type="submit"
|
||||
?disabled=${!this.connected ||
|
||||
!this.agentId ||
|
||||
!this.query.trim() ||
|
||||
this.searchState.kind === "loading"}
|
||||
>
|
||||
${t("memoryPage.memories.searchButton")}
|
||||
</button>
|
||||
</form>
|
||||
${this.renderSearchState()}`}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-memory-memories")) {
|
||||
customElements.define("openclaw-memory-memories", MemoryMemoriesElement);
|
||||
}
|
||||
@@ -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<typeof import("../../components/lobster-pet.ts")>();
|
||||
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<HTMLButtonElement>("button")].find((button) =>
|
||||
button.textContent?.includes("Search memories"),
|
||||
);
|
||||
shortcut?.click();
|
||||
expect(onNavigate).toHaveBeenCalledWith("memories");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DoctorMemoryStatusPayload["dreaming"]>;
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<openclaw-memory-memories
|
||||
.client=${this.context.gateway.snapshot.client}
|
||||
.connected=${this.context.gateway.snapshot.phase === "connected"}
|
||||
.methodAdvertised=${isGatewayMethodAdvertised(
|
||||
this.context.gateway.snapshot,
|
||||
"memory.search",
|
||||
) === true}
|
||||
.agentId=${agentId}
|
||||
.agents=${agents}
|
||||
.onAgentChange=${(next: string | null) => this.selectAgent(next)}
|
||||
></openclaw-memory-memories>
|
||||
`,
|
||||
dreams: html`
|
||||
<openclaw-memory-dreaming
|
||||
.agentId=${agentId}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { resolveSlotSelection } from "../../../../src/plugins/slots.ts";
|
||||
|
||||
export type MemoryTab = "overview" | "dreams" | "settings";
|
||||
export type MemoryTab = "overview" | "memories" | "dreams" | "settings";
|
||||
|
||||
export type MemoryBackend = "builtin" | "qmd";
|
||||
|
||||
@@ -26,7 +26,7 @@ export type MemoryEngineSelection =
|
||||
/** Scroll target for `memory.backend`, which Settings curates out of the editor. */
|
||||
export const MEMORY_BACKEND_ANCHOR_ID = "memory-backend";
|
||||
|
||||
const MEMORY_TABS: readonly MemoryTab[] = ["overview", "dreams", "settings"];
|
||||
const MEMORY_TABS: readonly MemoryTab[] = ["overview", "memories", "dreams", "settings"];
|
||||
|
||||
/** Reads a `?tab=` value from a settings-search destination or a shared link. */
|
||||
function normalizeMemoryTab(value: string | null | undefined): MemoryTab | null {
|
||||
|
||||
@@ -42,6 +42,7 @@ function createProps(overrides: Partial<MemoryViewProps> = {}): MemoryViewProps
|
||||
pluginsHref: "/settings/plugins",
|
||||
memoryImportHref: "/memory-import",
|
||||
overview: html`<div class="test-overview"></div>`,
|
||||
memories: html`<div class="test-memories"></div>`,
|
||||
dreams: html`<div class="test-dreams"></div>`,
|
||||
editor: html`<div class="test-editor"></div>`,
|
||||
dreamingSettings: html`<div class="test-dreaming-settings"></div>`,
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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) {
|
||||
<div id=${MEMORY_PANEL_ID} class="memory-page__panel" role="tabpanel">
|
||||
${props.activeTab === "overview"
|
||||
? props.overview
|
||||
: props.activeTab === "dreams"
|
||||
? props.dreams
|
||||
: renderSettingsTab(props)}
|
||||
: props.activeTab === "memories"
|
||||
? props.memories
|
||||
: props.activeTab === "dreams"
|
||||
? props.dreams
|
||||
: renderSettingsTab(props)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
92
ui/src/styles/memory-memories.css
Normal file
92
ui/src/styles/memory-memories.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user