mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-26 10:13:27 +08:00
refactor(memory): move overview presentation into Memory Core (#174452)
* refactor(memory): move overview into Memory Core * fix(memory): qualify Settings contract integration * fix(memory): prepare Settings presentation for UI-only delivery * test(ui): align catalog refresh clock with polling interval Match the two-minute refresh contract introduced by #174404 (fb6a79afee). Keep the existing cursor replay, retained-host, and history assertions. * test(ui): exercise optional hover loading outside Chat Chat eagerly imports the hover provider since #173652 (8e95a76d91). Use the real About Docs link to retain the failed-download, reload, title fallback, no-card, and no-preview-RPC assertions. * test(ui): count nested assets in compression progress Include nested packaged plugin assets in the observed sidecar totals and emit nested fixture assets so the exact cadence and failure assertions cover this layout. * test(ui): type recursive compression filenames as UTF-8
This commit is contained in:
1 parent
24f9be0a7c
commit
dbd0a216b2
60 files changed
+4779
-941
No files matched your search
@@ -357,6 +357,49 @@ Keep one renderer responsible for each container's children and dispose its root
|
||||
when the view ends. Solid 2 batches reactive writes; keep synchronous request
|
||||
authority and mutation guards with their existing owners.
|
||||
|
||||
### Packaged Settings presentations
|
||||
|
||||
A bundled presentation can render a host-owned Settings placement without
|
||||
activating the plugin's runtime UI. Memory Core uses this for the Memory overview,
|
||||
including when memory is off, another engine is selected, or the Gateway is offline.
|
||||
Declare the placement alongside the built entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"controlUi": {
|
||||
"entry": "dist/control-ui/<content-hash>/index.js",
|
||||
"activation": "settings-only",
|
||||
"settingsViews": [{ "section": "memory", "panel": "overview" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The browser module exports `pluginId` and a `settingsViews` array of
|
||||
`ControlUiSettingsView` from `openclaw/plugin-sdk/control-ui`. Each view declares
|
||||
its `placement` and a `mount(container, context)` that returns `update(snapshot)`
|
||||
and `dispose()`. The current supported placement is the Memory overview; this
|
||||
contract does not register arbitrary Settings routes.
|
||||
|
||||
The host supplies the selected Settings agent, engine availability, connection
|
||||
state, locale, shared Settings components, and bounded navigation. Authenticated
|
||||
operator RPC remains trusted native UI authority, bound to the mounted view's
|
||||
lifetime. Retained callbacks reject after that lifetime ends. The host keeps
|
||||
routing, agent selection, engine decisions, and config writes; the plugin owns
|
||||
its presentation and status requests. Only an explicit user action probes
|
||||
embedding readiness.
|
||||
|
||||
The normal plugin builder preserves this declaration. The Control UI build
|
||||
loads it from the completed bundled distribution with the same package identity,
|
||||
asset revision, and immutable JavaScript/CSS generation. A Settings-only entry
|
||||
is excluded from runtime UI activation; existing `defineControlUiPlugin` entries
|
||||
keep their required `activate(host)` contract.
|
||||
|
||||
Source-checkout `pnpm ui:build` and `pnpm ui:dev` prepare declared Settings
|
||||
presentations through the same plugin builder before reading that distribution.
|
||||
The dev server serves the validated generated JavaScript and CSS without runtime
|
||||
plugin activation. Restart the dev server after editing plugin presentation
|
||||
sources to rebuild their immutable generation.
|
||||
|
||||
### Bundle boundaries
|
||||
|
||||
The build emits a JavaScript entry, optional CSS, and JavaScript chunks for lazy
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/** @jsxImportSource @solidjs/web */
|
||||
import { Portal, type JSX } from "@solidjs/web";
|
||||
import type {
|
||||
ControlUiComponentHandle,
|
||||
ControlUiSettingsComponents,
|
||||
} from "openclaw/plugin-sdk/control-ui";
|
||||
import { createEffect, getOwner, onSettled, runWithOwner } from "solid-js";
|
||||
|
||||
type Section = Parameters<ControlUiSettingsComponents["mountSection"]>[1];
|
||||
type Row = Parameters<ControlUiSettingsComponents["mountRow"]>[1];
|
||||
|
||||
export function createSettingsComponents(components: ControlUiSettingsComponents) {
|
||||
function bridge<P extends object>(
|
||||
read: () => P,
|
||||
mount: (container: HTMLElement, props: P) => ControlUiComponentHandle<P>,
|
||||
) {
|
||||
const owner = getOwner();
|
||||
let container: HTMLElement | undefined;
|
||||
let handle: ControlUiComponentHandle<P> | undefined;
|
||||
// The host owns this visual island; apply its imperative update contract.
|
||||
createEffect(read, (props) => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (handle) {
|
||||
handle.update(props);
|
||||
} else {
|
||||
handle = runWithOwner(owner, () => mount(container!, props));
|
||||
}
|
||||
});
|
||||
onSettled(() => () => handle?.dispose());
|
||||
return (node: HTMLElement) => {
|
||||
container = node;
|
||||
};
|
||||
}
|
||||
const contentNode = () => {
|
||||
const node = document.createElement("span");
|
||||
node.style.display = "contents";
|
||||
return node;
|
||||
};
|
||||
function SettingsSection(props: { title: string; children?: JSX.Element }) {
|
||||
const content = contentNode();
|
||||
const mount = bridge<Section>(() => ({ title: props.title, content }), components.mountSection);
|
||||
return (
|
||||
<>
|
||||
<Portal mount={content}>{props.children}</Portal>
|
||||
<span style={{ display: "contents" }} ref={mount} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function SettingsRow(props: { title: string; description?: JSX.Element; control?: JSX.Element }) {
|
||||
const description = contentNode();
|
||||
const control = contentNode();
|
||||
const mount = bridge<Row>(
|
||||
() => ({
|
||||
title: props.title,
|
||||
description: props.description == null ? undefined : description,
|
||||
control,
|
||||
}),
|
||||
components.mountRow,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Portal mount={description}>{props.description}</Portal>
|
||||
<Portal mount={control}>{props.control}</Portal>
|
||||
<span style={{ display: "contents" }} ref={mount} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function SettingsStatus(props: Parameters<ControlUiSettingsComponents["mountStatus"]>[1]) {
|
||||
return (
|
||||
<span
|
||||
style={{ display: "contents" }}
|
||||
ref={bridge(() => ({ ...props }), components.mountStatus)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function SettingsValue(props: Parameters<ControlUiSettingsComponents["mountValue"]>[1]) {
|
||||
return (
|
||||
<span
|
||||
style={{ display: "contents" }}
|
||||
ref={bridge(() => ({ ...props }), components.mountValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function SettingsNavRow(props: Parameters<ControlUiSettingsComponents["mountNavigation"]>[1]) {
|
||||
return (
|
||||
<span
|
||||
style={{ display: "contents" }}
|
||||
ref={bridge(() => ({ ...props }), components.mountNavigation)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function Lobster(props: Parameters<ControlUiSettingsComponents["mountLobster"]>[1]) {
|
||||
return (
|
||||
<span
|
||||
style={{ display: "contents" }}
|
||||
ref={bridge(() => ({ ...props }), components.mountLobster)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return { SettingsSection, SettingsRow, SettingsStatus, SettingsValue, SettingsNavRow, Lobster };
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsSnapshot,
|
||||
DoctorMemoryStatusPayload,
|
||||
} from "openclaw/plugin-sdk/control-ui";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMemoryOverviewController } from "./controller.ts";
|
||||
|
||||
type MutableSnapshot = {
|
||||
-readonly [K in keyof ControlUiSettingsSnapshot]: ControlUiSettingsSnapshot[K];
|
||||
};
|
||||
const snapshot = (): MutableSnapshot => ({
|
||||
agentId: "main",
|
||||
agentsLoading: false,
|
||||
agentsError: null,
|
||||
engine: { kind: "default", id: "memory-core", availability: "enabled" },
|
||||
connected: true,
|
||||
connectionEpoch: 1,
|
||||
routeIntent: "overview",
|
||||
locale: "en",
|
||||
});
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function fixture(props = snapshot()) {
|
||||
const abort = new AbortController();
|
||||
const pending: ReturnType<typeof deferred<DoctorMemoryStatusPayload>>[] = [];
|
||||
const request = vi.fn(() => {
|
||||
const next = deferred<DoctorMemoryStatusPayload>();
|
||||
pending.push(next);
|
||||
return next.promise;
|
||||
});
|
||||
const refreshAgents = vi.fn();
|
||||
const changed = vi.fn();
|
||||
const context = {
|
||||
props,
|
||||
signal: abort.signal,
|
||||
request,
|
||||
refreshAgents,
|
||||
formatError: String,
|
||||
} as unknown as ControlUiSettingsContext;
|
||||
const controller = createMemoryOverviewController(context, changed, () => "offline");
|
||||
return { controller, pending, request, refreshAgents, abort };
|
||||
}
|
||||
const payload = (agentId: string): DoctorMemoryStatusPayload => ({
|
||||
agentId,
|
||||
embedding: { ok: true },
|
||||
});
|
||||
|
||||
describe("Memory overview request ownership", () => {
|
||||
it("deduplicates ordinary reads and probes only on explicit action", async () => {
|
||||
const f = fixture();
|
||||
const read = f.controller.load();
|
||||
await f.controller.load();
|
||||
expect(f.request).toHaveBeenCalledTimes(1);
|
||||
expect(f.request).toHaveBeenLastCalledWith("doctor.memory.status", { agentId: "main" });
|
||||
f.pending[0]!.resolve(payload("main"));
|
||||
await read;
|
||||
f.controller.probe();
|
||||
expect(f.request).toHaveBeenLastCalledWith("doctor.memory.status", {
|
||||
agentId: "main",
|
||||
probe: true,
|
||||
});
|
||||
expect(f.controller.read().probingEmbeddings).toBe(true);
|
||||
f.pending[1]!.resolve(payload("probed"));
|
||||
await Promise.resolve();
|
||||
expect(f.controller.read().status).toEqual({ kind: "ready", payload: payload("probed") });
|
||||
expect(f.controller.read().probingEmbeddings).toBe(false);
|
||||
});
|
||||
it.each(["agent", "engine", "epoch", "route"])(
|
||||
"retires an old probe when %s authority changes",
|
||||
async (kind) => {
|
||||
const f = fixture();
|
||||
f.controller.probe();
|
||||
const next = snapshot();
|
||||
if (kind === "agent") {
|
||||
next.agentId = "writer";
|
||||
}
|
||||
if (kind === "engine") {
|
||||
next.engine = { kind: "pinned", id: "custom-memory", availability: "unknown" };
|
||||
}
|
||||
if (kind === "epoch") {
|
||||
next.connectionEpoch = 2;
|
||||
}
|
||||
if (kind === "route") {
|
||||
next.routeIntent = "overview?agent=writer";
|
||||
}
|
||||
f.controller.update(next);
|
||||
expect(f.request).toHaveBeenCalledTimes(2);
|
||||
expect(f.controller.read().probingEmbeddings).toBe(false);
|
||||
f.pending[0]!.resolve(payload("stale"));
|
||||
await Promise.resolve();
|
||||
expect(f.controller.read().status).toEqual({ kind: "loading" });
|
||||
f.pending[1]!.resolve(payload("fresh"));
|
||||
await Promise.resolve();
|
||||
expect(f.controller.read().status).toEqual({ kind: "ready", payload: payload("fresh") });
|
||||
},
|
||||
);
|
||||
it.each(["off", "offline", "no-agent"])(
|
||||
"clears pending status and probe when %s",
|
||||
async (kind) => {
|
||||
const f = fixture();
|
||||
f.controller.probe();
|
||||
const next = snapshot();
|
||||
if (kind === "off") {
|
||||
next.engine = { kind: "off", id: null, availability: "unknown" };
|
||||
}
|
||||
if (kind === "offline") {
|
||||
next.connected = false;
|
||||
}
|
||||
if (kind === "no-agent") {
|
||||
next.agentId = null;
|
||||
}
|
||||
f.controller.update(next);
|
||||
expect(f.controller.read().probingEmbeddings).toBe(false);
|
||||
expect(f.request).toHaveBeenCalledTimes(1);
|
||||
f.pending[0]!.resolve(payload("stale"));
|
||||
await Promise.resolve();
|
||||
expect(f.controller.read().status).toEqual(
|
||||
kind === "offline" ? { kind: "error", message: "offline" } : { kind: "idle" },
|
||||
);
|
||||
},
|
||||
);
|
||||
it("does not cache a roster error when no agent is selected", async () => {
|
||||
const props = snapshot();
|
||||
props.agentId = null;
|
||||
props.agentsError = "roster failed";
|
||||
const f = fixture(props);
|
||||
await f.controller.load();
|
||||
expect(f.controller.read().status).toEqual({ kind: "idle" });
|
||||
f.controller.update({ ...props, agentsError: null, agentsLoading: true });
|
||||
expect(f.controller.read().snapshot.agentsError).toBeNull();
|
||||
f.controller.update({ ...props, agentsError: null, agentsLoading: false });
|
||||
expect(f.controller.read().status).toEqual({ kind: "idle" });
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
it("retains disabled-engine reads and makes retired callbacks harmless", async () => {
|
||||
const props = snapshot();
|
||||
props.engine = { ...props.engine, availability: "disabled" };
|
||||
const f = fixture(props);
|
||||
const read = f.controller.load();
|
||||
expect(f.request).toHaveBeenCalledTimes(1);
|
||||
f.abort.abort();
|
||||
f.controller.refresh();
|
||||
f.controller.probe();
|
||||
await f.controller.load();
|
||||
f.pending[0]!.resolve(payload("stale"));
|
||||
await read;
|
||||
expect(f.request).toHaveBeenCalledTimes(1);
|
||||
expect(f.controller.read().status).toEqual({ kind: "idle" });
|
||||
const noAgent = snapshot();
|
||||
noAgent.agentId = null;
|
||||
const retired = fixture(noAgent);
|
||||
retired.controller.dispose();
|
||||
retired.controller.refresh();
|
||||
expect(retired.refreshAgents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsSnapshot,
|
||||
DoctorMemoryStatusPayload,
|
||||
} from "openclaw/plugin-sdk/control-ui";
|
||||
|
||||
export type MemoryOverviewStatus =
|
||||
| { kind: "idle" | "loading" }
|
||||
| { kind: "ready"; payload: DoctorMemoryStatusPayload }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function createMemoryOverviewController(
|
||||
context: ControlUiSettingsContext,
|
||||
changed: () => void,
|
||||
offlineMessage: () => string,
|
||||
) {
|
||||
let snapshot = context.props;
|
||||
let status: MemoryOverviewStatus = { kind: "idle" };
|
||||
let probingEmbeddings = false;
|
||||
let request: object | undefined;
|
||||
let active = true;
|
||||
const scope = (value: ControlUiSettingsSnapshot) =>
|
||||
JSON.stringify([
|
||||
value.agentId,
|
||||
value.engine,
|
||||
value.connected,
|
||||
value.connectionEpoch,
|
||||
value.routeIntent,
|
||||
]);
|
||||
const invalidate = () => {
|
||||
request = undefined;
|
||||
probingEmbeddings = false;
|
||||
status = { kind: "idle" };
|
||||
};
|
||||
const dispose = () => {
|
||||
active = false;
|
||||
invalidate();
|
||||
};
|
||||
context.signal.addEventListener("abort", dispose, { once: true });
|
||||
const read = () => ({ snapshot, status, probingEmbeddings });
|
||||
const load = async (force = false, probe = false) => {
|
||||
if (!active || context.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (snapshot.engine.kind === "off") {
|
||||
invalidate();
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
if (!snapshot.connected) {
|
||||
invalidate();
|
||||
status = { kind: "error", message: offlineMessage() };
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
if (!snapshot.agentId) {
|
||||
invalidate();
|
||||
status = { kind: "idle" };
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
if (request && !force) {
|
||||
return;
|
||||
}
|
||||
const pending = {};
|
||||
request = pending;
|
||||
probingEmbeddings = probe;
|
||||
if (!probe) {
|
||||
status = { kind: "loading" };
|
||||
}
|
||||
changed();
|
||||
try {
|
||||
const payload = await context.request<DoctorMemoryStatusPayload>("doctor.memory.status", {
|
||||
agentId: snapshot.agentId,
|
||||
...(probe ? { probe: true } : {}),
|
||||
});
|
||||
if (!active || request !== pending || context.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
status = { kind: "ready", payload };
|
||||
} catch (error) {
|
||||
if (!active || request !== pending || context.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
status = { kind: "error", message: context.formatError(error) };
|
||||
} finally {
|
||||
if (active && request === pending && !context.signal.aborted) {
|
||||
probingEmbeddings = false;
|
||||
changed();
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
read,
|
||||
update(next: ControlUiSettingsSnapshot) {
|
||||
if (!active || context.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const changedScope = scope(snapshot) !== scope(next);
|
||||
snapshot = next;
|
||||
if (changedScope) {
|
||||
invalidate();
|
||||
}
|
||||
changed();
|
||||
if (changedScope) {
|
||||
void load();
|
||||
}
|
||||
},
|
||||
load,
|
||||
refresh() {
|
||||
if (!active || context.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (snapshot.agentId) {
|
||||
void load(true);
|
||||
} else {
|
||||
context.refreshAgents();
|
||||
}
|
||||
},
|
||||
probe() {
|
||||
void load(true, true);
|
||||
},
|
||||
dispose() {
|
||||
dispose();
|
||||
context.signal.removeEventListener("abort", dispose);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Memory overview translations
|
||||
|
||||
`locales/en.ts` owns the overview English catalog. `locales/translated.json` retains the existing host translations with matching English hashes. Missing translations fall back to English.
|
||||
|
||||
The repository Control UI i18n verifier and locale refresh discover this catalog beside the declared browser entry. Keep generated translation memory updates separate from source changes, as for Workboard.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { messages, type TranslationMap } from "./locales.ts";
|
||||
|
||||
function lookup(locale: string, key: string): unknown {
|
||||
let value: string | TranslationMap | undefined = messages[locale];
|
||||
for (const part of key.split(".")) {
|
||||
value = value && typeof value === "object" ? value[part] : undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createTranslator(locale: () => string) {
|
||||
return (key: string, params?: Record<string, string>): string => {
|
||||
const value = lookup(locale(), key) ?? lookup("en", key);
|
||||
return typeof value === "string"
|
||||
? value.replace(/\{(\w+)\}/g, (_, name: string) => params?.[name] ?? `{${name}}`)
|
||||
: key;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import en from "./locales/en.ts";
|
||||
import translated from "./locales/translated.json" with { type: "json" };
|
||||
export type TranslationMap = { readonly [key: string]: string | TranslationMap };
|
||||
export const messages: Readonly<Record<string, TranslationMap>> = { en, ...translated };
|
||||
@@ -0,0 +1,88 @@
|
||||
const en = {
|
||||
common: {
|
||||
loading: "Loading…",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
na: "n/a",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
memoryPage: {
|
||||
overview: {
|
||||
hero: {
|
||||
awake: "Memory is awake",
|
||||
waking: "Waking memory…",
|
||||
hibernating: "Memory is hibernating",
|
||||
needsAttention: "Memory needs attention",
|
||||
noSearchRuntime: "Host memory search is unavailable",
|
||||
noSearchRuntimeDescription:
|
||||
"{engine} does not provide a host memory search runtime. Other memory integrations may run independently.",
|
||||
activeDescription: "{engine} · {mode}",
|
||||
loadingDescription: "Checking this agent's memory engine and dream cycle.",
|
||||
offDescription: "Choose a memory engine in Settings to wake it up.",
|
||||
disabledDescription: "The selected memory engine is disabled. Re-enable it in Settings.",
|
||||
gatewayOffline: "The gateway is offline, so memory status is unavailable.",
|
||||
hybridSearch: "hybrid search",
|
||||
keywordSearch: "keyword search (no embeddings)",
|
||||
openSettings: "Open Settings",
|
||||
retry: "Retry",
|
||||
refresh: "Refresh status",
|
||||
},
|
||||
schedule: {
|
||||
title: "Sleep schedule",
|
||||
lightDescription:
|
||||
"Sorts fresh short-term notes and stages promising candidates without changing long-term memory.",
|
||||
remDescription:
|
||||
"Reflects on themes and recurring ideas across recent activity to strengthen ranking without changing long-term memory.",
|
||||
deepDescription:
|
||||
"Scores staged candidates, promotes the keepers into long-term memory (MEMORY.md), and writes the dream diary.",
|
||||
nextRun: "next {time}",
|
||||
lastRun: "last {time}",
|
||||
notScheduled: "Not scheduled",
|
||||
learnMore: "How dreaming works",
|
||||
openDocs: "Open dreaming guide",
|
||||
},
|
||||
activity: {
|
||||
title: "Activity",
|
||||
promotedToday: "Promoted today",
|
||||
promotedTotal: "Promoted total",
|
||||
shortTermCount: "Pending short-term entries",
|
||||
phaseHitCount: "Phase signals",
|
||||
lightPhaseHitCount: "Light-phase hits",
|
||||
remPhaseHitCount: "REM-phase hits",
|
||||
},
|
||||
health: {
|
||||
title: "Engine health",
|
||||
provider: "Provider",
|
||||
embeddings: "Embeddings",
|
||||
runtime: "Embedding runtime",
|
||||
healthy: "Ready",
|
||||
unavailable: "Unavailable",
|
||||
notChecked: "Not checked",
|
||||
notCheckedDescription: "Embedding readiness has not been checked yet.",
|
||||
checking: "Checking…",
|
||||
test: "Test",
|
||||
testing: "Testing…",
|
||||
},
|
||||
shortcuts: {
|
||||
title: "Explore memory",
|
||||
memories: "Search memories",
|
||||
diary: "Read the dream diary",
|
||||
settings: "Configure memory",
|
||||
},
|
||||
},
|
||||
dreaming: {
|
||||
phases: {
|
||||
light: {
|
||||
title: "Light phase",
|
||||
},
|
||||
deep: {
|
||||
title: "Deep phase",
|
||||
},
|
||||
rem: {
|
||||
title: "REM phase",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export default en;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,64 @@
|
||||
/** @jsxImportSource @solidjs/web */
|
||||
import { render } from "@solidjs/web";
|
||||
import type { ControlUiSettingsView } from "openclaw/plugin-sdk/control-ui";
|
||||
import { createSignal } from "solid-js";
|
||||
import { createMemoryOverviewController } from "./controller.ts";
|
||||
import { createTranslator } from "./i18n/index.ts";
|
||||
import { createMemoryOverview } from "./overview.tsx";
|
||||
|
||||
export const pluginId = "memory-core";
|
||||
export const settingsViews: readonly ControlUiSettingsView[] = [
|
||||
{
|
||||
placement: { section: "memory", panel: "overview" },
|
||||
mount(container, context) {
|
||||
// Only this imperative view lifetime writes its reactive projection.
|
||||
const [state, setState] = createSignal<
|
||||
ReturnType<ReturnType<typeof createMemoryOverviewController>["read"]>
|
||||
>(
|
||||
{ snapshot: context.props, status: { kind: "idle" }, probingEmbeddings: false },
|
||||
{ ownedWrite: true },
|
||||
);
|
||||
const t = createTranslator(() => state().snapshot.locale);
|
||||
const controller = createMemoryOverviewController(
|
||||
context,
|
||||
() => setState(controller.read()),
|
||||
() => t("memoryPage.overview.hero.gatewayOffline"),
|
||||
);
|
||||
const MemoryOverview = createMemoryOverview(context, () => state().snapshot);
|
||||
const stop = render(
|
||||
() => (
|
||||
<MemoryOverview
|
||||
agentId={state().snapshot.agentId}
|
||||
engineSelection={state().snapshot.engine}
|
||||
engineDisabled={state().snapshot.engine.availability === "disabled"}
|
||||
status={
|
||||
state().snapshot.agentId
|
||||
? state().status
|
||||
: state().snapshot.agentsError
|
||||
? { kind: "error", message: state().snapshot.agentsError! }
|
||||
: state().status
|
||||
}
|
||||
probingEmbeddings={state().probingEmbeddings}
|
||||
onRefresh={() => controller.refresh()}
|
||||
onProbeEmbeddings={() => controller.probe()}
|
||||
onNavigate={context.navigate}
|
||||
/>
|
||||
),
|
||||
container,
|
||||
);
|
||||
void controller.load();
|
||||
return {
|
||||
update(snapshot) {
|
||||
controller.update(snapshot);
|
||||
},
|
||||
dispose() {
|
||||
try {
|
||||
controller.dispose();
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
File renamed without changes.
+158
-26
@@ -1,30 +1,123 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-methods/doctor.ts";
|
||||
import { mountSolid } from "../../test-helpers/mount-solid.ts";
|
||||
import { flush } from "../../test-helpers/solid-settle.ts";
|
||||
import { render } from "@solidjs/web";
|
||||
import type {
|
||||
ControlUiComponentHandle,
|
||||
ControlUiSettingsComponents,
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsSnapshot,
|
||||
DoctorMemoryStatusPayload,
|
||||
} from "openclaw/plugin-sdk/control-ui";
|
||||
import { flush, untrack } from "solid-js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// 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-look.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../components/lobster-pet-look.ts")>();
|
||||
const { LOBSTER_PET_PALETTES } = await import("../../components/lobster-pet-palettes.ts");
|
||||
return {
|
||||
...actual,
|
||||
createLobsterPetLook: () =>
|
||||
actual.canonicalLobsterLook(
|
||||
expectDefined(LOBSTER_PET_PALETTES[0], "canonical lobster palette"),
|
||||
),
|
||||
};
|
||||
const disposers: (() => void)[] = [];
|
||||
afterEach(() => {
|
||||
for (const dispose of disposers.splice(0)) {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
const waitForSolid = (assertion: () => void) =>
|
||||
vi.waitFor(() => {
|
||||
flush();
|
||||
assertion();
|
||||
});
|
||||
const formatRelativeTimestamp = String;
|
||||
function mountSolid(view: Parameters<typeof render>[0]) {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const dispose = render(view, container);
|
||||
disposers.push(() => {
|
||||
dispose();
|
||||
container.remove();
|
||||
});
|
||||
return { container };
|
||||
}
|
||||
// SDK component doubles expose content and callbacks, not host styling or artwork.
|
||||
function createTestComponents(): ControlUiSettingsComponents {
|
||||
function mount<P>(
|
||||
container: HTMLElement,
|
||||
initial: P,
|
||||
draw: (props: P) => HTMLElement,
|
||||
): ControlUiComponentHandle<P> {
|
||||
const update = (props: P) => container.replaceChildren(draw(props));
|
||||
update(initial);
|
||||
return { update, dispose: () => container.replaceChildren() };
|
||||
}
|
||||
const text = (tag: string, ...content: (string | number | HTMLElement | undefined)[]) => {
|
||||
const element = document.createElement(tag);
|
||||
for (const child of content) {
|
||||
if (child !== undefined) {
|
||||
element.append(typeof child === "number" ? String(child) : child);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
};
|
||||
return {
|
||||
mountSection: (container, props) =>
|
||||
mount(container, props, (next) => text("section", text("h3", next.title), next.content)),
|
||||
mountRow: (container, props) =>
|
||||
mount(container, props, (next) => {
|
||||
const row = text("div", next.title, next.description, next.control);
|
||||
row.className = "settings-row";
|
||||
return row;
|
||||
}),
|
||||
mountStatus: (container, props) => mount(container, props, (next) => text("span", next.label)),
|
||||
mountValue: (container, props) => mount(container, props, (next) => text("span", next.value)),
|
||||
mountNavigation: (container, props) =>
|
||||
mount(container, props, (next) => {
|
||||
const button = text("button", next.title);
|
||||
button.addEventListener("click", next.onClick);
|
||||
return button;
|
||||
}),
|
||||
mountLobster: (container, props) =>
|
||||
mount(container, props, (next) => {
|
||||
const pet = text("span");
|
||||
pet.dataset.testLobster = "";
|
||||
pet.dataset.reading = String(next.reading === true);
|
||||
pet.dataset.sleeping = String(next.sleeping === true);
|
||||
return pet;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
import { MemoryOverview, type MemoryOverviewStatus } from "./memory-overview.tsx";
|
||||
import type { MemoryOverviewStatus } from "./controller.ts";
|
||||
import { settingsViews } from "./index.tsx";
|
||||
import { createMemoryOverview } from "./overview.tsx";
|
||||
|
||||
type MemoryOverviewProps = Parameters<typeof MemoryOverview>[0];
|
||||
type ViewProps = Parameters<ReturnType<typeof createMemoryOverview>>[0];
|
||||
type MemoryOverviewProps = Omit<ViewProps, "engineSelection"> & {
|
||||
engineSelection: { kind: "default" | "pinned" | "off"; pluginId?: string };
|
||||
};
|
||||
function MemoryOverview(props: MemoryOverviewProps) {
|
||||
const signal = new AbortController().signal;
|
||||
const snapshot: ControlUiSettingsSnapshot = untrack(() => ({
|
||||
agentId: props.agentId,
|
||||
agentsLoading: false,
|
||||
agentsError: null,
|
||||
engine: {
|
||||
kind: props.engineSelection.kind,
|
||||
id: props.engineSelection.pluginId ?? null,
|
||||
availability: "enabled",
|
||||
},
|
||||
connected: true,
|
||||
connectionEpoch: 1,
|
||||
routeIntent: "overview",
|
||||
locale: "en",
|
||||
}));
|
||||
const context: ControlUiSettingsContext = {
|
||||
props: snapshot,
|
||||
signal,
|
||||
components: createTestComponents(),
|
||||
formatRelativeTimestamp,
|
||||
request: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
refreshAgents: vi.fn(),
|
||||
formatError: String,
|
||||
};
|
||||
const View = createMemoryOverview(context, () => snapshot);
|
||||
return <View {...props} engineSelection={snapshot.engine} />;
|
||||
}
|
||||
|
||||
function fixturePayload(): DoctorMemoryStatusPayload {
|
||||
const phaseBase = {
|
||||
@@ -118,7 +211,7 @@ describe("MemoryOverview", () => {
|
||||
|
||||
expect(container.textContent).toContain("Memory is awake");
|
||||
expect(container.textContent).toContain("memory-core · hybrid search");
|
||||
expect(container.querySelector(".lob-reading-book")).not.toBeNull();
|
||||
expect(container.querySelector('[data-reading="true"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Sleep schedule");
|
||||
expect(container.textContent).toContain("Europe/Vienna");
|
||||
expect(container.textContent).toContain("Promoted today");
|
||||
@@ -133,7 +226,7 @@ describe("MemoryOverview", () => {
|
||||
expect(container.textContent).toContain("Memory needs attention");
|
||||
expect(container.textContent).toContain("gateway request failed");
|
||||
expect(container.textContent).toContain("Retry");
|
||||
expect(container.querySelector(".lob-reading-book")).toBeNull();
|
||||
expect(container.querySelector('[data-reading="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the dimmed sleeping hero when the engine is off", () => {
|
||||
@@ -142,8 +235,8 @@ describe("MemoryOverview", () => {
|
||||
expect(container.textContent).toContain("Memory is hibernating");
|
||||
expect(container.textContent).toContain("Open Settings");
|
||||
expect(container.querySelector(".memory-overview__hero--sleeping")).not.toBeNull();
|
||||
expect(container.querySelector(".lobster-pet__svg")).not.toBeNull();
|
||||
expect(container.querySelector(".lob-reading-book")).toBeNull();
|
||||
expect(container.querySelector("[data-test-lobster]")).not.toBeNull();
|
||||
expect(container.querySelector('[data-reading="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("hibernates a disabled pinned engine and points to Settings", () => {
|
||||
@@ -156,7 +249,7 @@ describe("MemoryOverview", () => {
|
||||
expect(container.textContent).toContain("Memory is hibernating");
|
||||
expect(container.textContent).toContain("selected memory engine is disabled");
|
||||
expect(container.textContent).toContain("Open Settings");
|
||||
expect(container.querySelector(".lob-reading-book")).toBeNull();
|
||||
expect(container.querySelector('[data-reading="true"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Engine health");
|
||||
expect(container.textContent).not.toContain("Sleep schedule");
|
||||
});
|
||||
@@ -246,3 +339,42 @@ describe("MemoryOverview", () => {
|
||||
expect(onNavigate).toHaveBeenCalledWith("memories");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a roster error in the mounted plugin without issuing a Doctor request", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const signal = new AbortController().signal;
|
||||
const props: ControlUiSettingsSnapshot = {
|
||||
agentId: null,
|
||||
agentsLoading: false,
|
||||
agentsError: "roster failed",
|
||||
engine: { kind: "default", id: "memory-core", availability: "enabled" },
|
||||
connected: true,
|
||||
connectionEpoch: 1,
|
||||
routeIntent: "overview",
|
||||
locale: "en",
|
||||
};
|
||||
const request = vi.fn();
|
||||
const context: ControlUiSettingsContext = {
|
||||
signal,
|
||||
props,
|
||||
components: createTestComponents(),
|
||||
request,
|
||||
navigate: vi.fn(),
|
||||
refreshAgents: vi.fn(),
|
||||
formatError: String,
|
||||
formatRelativeTimestamp,
|
||||
};
|
||||
const view = settingsViews[0]!.mount(container, context);
|
||||
try {
|
||||
await waitForSolid(() => expect(container.textContent).toContain("roster failed"));
|
||||
view.update({ ...props, agentsError: null, agentsLoading: true });
|
||||
await waitForSolid(() => expect(container.textContent).not.toContain("roster failed"));
|
||||
view.update({ ...props, agentsError: null });
|
||||
await waitForSolid(() => expect(container.textContent).not.toContain("roster failed"));
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
view.dispose();
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
/** @jsxImportSource @solidjs/web */
|
||||
import type {
|
||||
DoctorMemoryStatusPayload,
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsSnapshot,
|
||||
} from "openclaw/plugin-sdk/control-ui";
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { createSettingsComponents } from "./components.tsx";
|
||||
import type { MemoryOverviewStatus } from "./controller.ts";
|
||||
import { createTranslator } from "./i18n/index.ts";
|
||||
import "./overview.css";
|
||||
|
||||
type MemoryOverviewProps = {
|
||||
agentId: string | null;
|
||||
engineSelection: ControlUiSettingsSnapshot["engine"];
|
||||
engineDisabled: boolean;
|
||||
status: MemoryOverviewStatus;
|
||||
probingEmbeddings: boolean;
|
||||
onRefresh: () => void;
|
||||
onProbeEmbeddings: () => void;
|
||||
onNavigate: (tab: "memories" | "dreams" | "settings") => void;
|
||||
};
|
||||
|
||||
type DreamingStatus = NonNullable<DoctorMemoryStatusPayload["dreaming"]>;
|
||||
type DreamingPhase = DreamingStatus["phases"][keyof DreamingStatus["phases"]] & {
|
||||
lastRunAtMs?: number;
|
||||
};
|
||||
|
||||
export function createMemoryOverview(
|
||||
context: ControlUiSettingsContext,
|
||||
snapshot: () => ControlUiSettingsSnapshot,
|
||||
) {
|
||||
const t = createTranslator(() => snapshot().locale);
|
||||
const formatRelativeTimestamp = context.formatRelativeTimestamp;
|
||||
const { SettingsSection, SettingsRow, SettingsStatus, SettingsValue, SettingsNavRow, Lobster } =
|
||||
createSettingsComponents(context.components);
|
||||
|
||||
function hasEmbeddingError(payload: DoctorMemoryStatusPayload): boolean {
|
||||
return !payload.embedding.ok && payload.embedding.checked !== false;
|
||||
}
|
||||
|
||||
function searchMode(payload: DoctorMemoryStatusPayload): string {
|
||||
return payload.provider === "none"
|
||||
? t("memoryPage.overview.hero.keywordSearch")
|
||||
: t("memoryPage.overview.hero.hybridSearch");
|
||||
}
|
||||
|
||||
function Hero(props: MemoryOverviewProps) {
|
||||
const engineId = () => props.engineSelection.id;
|
||||
const off = () => props.engineSelection.kind === "off" || props.engineDisabled;
|
||||
const readyPayload = createMemo(() =>
|
||||
props.status.kind === "ready" ? props.status.payload : null,
|
||||
);
|
||||
const noSearchRuntime = () => readyPayload()?.searchRuntimeRegistered === false;
|
||||
const error = () => {
|
||||
const payload = readyPayload();
|
||||
return (
|
||||
props.status.kind === "error" ||
|
||||
(!noSearchRuntime() && payload !== null && hasEmbeddingError(payload))
|
||||
);
|
||||
};
|
||||
const headline = () =>
|
||||
off()
|
||||
? t("memoryPage.overview.hero.hibernating")
|
||||
: props.status.kind === "loading" || props.status.kind === "idle"
|
||||
? t("memoryPage.overview.hero.waking")
|
||||
: noSearchRuntime()
|
||||
? t("memoryPage.overview.hero.noSearchRuntime")
|
||||
: error()
|
||||
? t("memoryPage.overview.hero.needsAttention")
|
||||
: t("memoryPage.overview.hero.awake");
|
||||
const description = () => {
|
||||
const payload = readyPayload();
|
||||
return off()
|
||||
? t(
|
||||
props.engineDisabled
|
||||
? "memoryPage.overview.hero.disabledDescription"
|
||||
: "memoryPage.overview.hero.offDescription",
|
||||
)
|
||||
: props.status.kind === "error"
|
||||
? props.status.message
|
||||
: payload
|
||||
? noSearchRuntime()
|
||||
? t("memoryPage.overview.hero.noSearchRuntimeDescription", {
|
||||
engine: engineId() ?? t("common.unknown"),
|
||||
})
|
||||
: hasEmbeddingError(payload)
|
||||
? (payload.embedding.error ?? t("memoryPage.overview.health.unavailable"))
|
||||
: t("memoryPage.overview.hero.activeDescription", {
|
||||
engine: engineId() ?? t("common.unknown"),
|
||||
mode: searchMode(payload),
|
||||
})
|
||||
: t("memoryPage.overview.hero.loadingDescription");
|
||||
};
|
||||
const pose = createMemo(() =>
|
||||
off()
|
||||
? { sleeping: true }
|
||||
: error()
|
||||
? { grumpy: true, standalone: true }
|
||||
: readyPayload() && !noSearchRuntime()
|
||||
? { reading: true, standalone: true }
|
||||
: { standalone: true },
|
||||
);
|
||||
return (
|
||||
<section class={["memory-overview__hero", { "memory-overview__hero--sleeping": off() }]}>
|
||||
<div class="memory-overview__lobster">
|
||||
<Lobster seed={props.agentId ?? "memory"} {...pose()} />
|
||||
</div>
|
||||
<div class="memory-overview__hero-copy">
|
||||
<h2>{headline()}</h2>
|
||||
<p class={{ "memory-overview__hero-error": error() }}>{description()}</p>
|
||||
<div class="memory-overview__hero-actions">
|
||||
{off() ? (
|
||||
<button class="btn btn--sm" onClick={() => props.onNavigate("settings")}>
|
||||
{t("memoryPage.overview.hero.openSettings")}
|
||||
</button>
|
||||
) : (
|
||||
<button class="btn btn--sm" onClick={props.onRefresh}>
|
||||
{props.status.kind === "error"
|
||||
? t("memoryPage.overview.hero.retry")
|
||||
: t("memoryPage.overview.hero.refresh")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseScheduleDescription(
|
||||
phase: DreamingPhase,
|
||||
timezone: string | undefined,
|
||||
scheduled: boolean,
|
||||
) {
|
||||
const details = [
|
||||
phase.cron || t("common.na"),
|
||||
timezone,
|
||||
scheduled && phase.nextRunAtMs
|
||||
? t("memoryPage.overview.schedule.nextRun", {
|
||||
time: formatRelativeTimestamp(phase.nextRunAtMs),
|
||||
})
|
||||
: null,
|
||||
phase.lastRunAtMs
|
||||
? t("memoryPage.overview.schedule.lastRun", {
|
||||
time: formatRelativeTimestamp(phase.lastRunAtMs),
|
||||
})
|
||||
: null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
return details.join(" · ");
|
||||
}
|
||||
|
||||
function Schedule(props: { dreaming: DreamingStatus }) {
|
||||
const phases = createMemo(
|
||||
() =>
|
||||
[
|
||||
["light", props.dreaming.phases.light],
|
||||
["rem", props.dreaming.phases.rem],
|
||||
["deep", props.dreaming.phases.deep],
|
||||
] as const,
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={t("memoryPage.overview.schedule.title")}>
|
||||
<>
|
||||
<For each={phases()} keyed={(entry) => entry[0]}>
|
||||
{(entry) => (
|
||||
<SettingsRow
|
||||
title={t(`memoryPage.dreaming.phases.${entry()[0]}.title`)}
|
||||
description={
|
||||
<>
|
||||
{t(`memoryPage.overview.schedule.${entry()[0]}Description`)}
|
||||
<br />
|
||||
{phaseScheduleDescription(
|
||||
entry()[1],
|
||||
props.dreaming.timezone,
|
||||
props.dreaming.enabled && entry()[1].enabled && entry()[1].managedCronPresent,
|
||||
)}
|
||||
</>
|
||||
}
|
||||
control={
|
||||
<SettingsStatus
|
||||
kind={
|
||||
props.dreaming.enabled && entry()[1].enabled && entry()[1].managedCronPresent
|
||||
? "ok"
|
||||
: "muted"
|
||||
}
|
||||
label={
|
||||
!props.dreaming.enabled || !entry()[1].enabled
|
||||
? t("common.disabled")
|
||||
: entry()[1].managedCronPresent
|
||||
? t("common.enabled")
|
||||
: t("memoryPage.overview.schedule.notScheduled")
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<SettingsRow
|
||||
title={t("memoryPage.overview.schedule.learnMore")}
|
||||
control={
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
class="memory-page__link"
|
||||
href="https://docs.openclaw.ai/concepts/dreaming"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t("memoryPage.overview.schedule.openDocs")}
|
||||
</a>{" "}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function Activity(props: { dreaming: DreamingStatus }) {
|
||||
const rows = createMemo(
|
||||
() =>
|
||||
[
|
||||
["promotedToday", props.dreaming.promotedToday],
|
||||
["promotedTotal", props.dreaming.promotedTotal],
|
||||
["shortTermCount", props.dreaming.shortTermCount],
|
||||
["phaseHitCount", props.dreaming.phaseSignalCount],
|
||||
["lightPhaseHitCount", props.dreaming.lightPhaseHitCount],
|
||||
["remPhaseHitCount", props.dreaming.remPhaseHitCount],
|
||||
] as const,
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={t("memoryPage.overview.activity.title")}>
|
||||
<For each={rows()} keyed={(entry) => entry[0]}>
|
||||
{(entry) => (
|
||||
<SettingsRow
|
||||
title={t(`memoryPage.overview.activity.${entry()[0]}`)}
|
||||
control={<SettingsValue value={entry()[1]} />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineHealth(props: {
|
||||
payload: DoctorMemoryStatusPayload;
|
||||
overview: MemoryOverviewProps;
|
||||
}) {
|
||||
const notChecked = () => props.payload.embedding.checked === false;
|
||||
const embeddingKind = () =>
|
||||
props.payload.embedding.ok ? "ok" : notChecked() ? "muted" : "danger";
|
||||
const embeddingLabel = () =>
|
||||
props.overview.probingEmbeddings
|
||||
? t("memoryPage.overview.health.checking")
|
||||
: props.payload.embedding.ok
|
||||
? t("memoryPage.overview.health.healthy")
|
||||
: notChecked()
|
||||
? t("memoryPage.overview.health.notChecked")
|
||||
: t("memoryPage.overview.health.unavailable");
|
||||
return (
|
||||
<SettingsSection title={t("memoryPage.overview.health.title")}>
|
||||
<>
|
||||
<SettingsRow
|
||||
title={t("memoryPage.overview.health.provider")}
|
||||
control={
|
||||
<SettingsValue mono={true} value={props.payload.provider ?? t("common.unknown")} />
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
title={t("memoryPage.overview.health.embeddings")}
|
||||
description={
|
||||
props.payload.embedding.ok
|
||||
? undefined
|
||||
: notChecked()
|
||||
? t("memoryPage.overview.health.notCheckedDescription")
|
||||
: props.payload.embedding.error
|
||||
}
|
||||
control={
|
||||
<>
|
||||
<SettingsStatus kind={embeddingKind()} label={embeddingLabel()} />
|
||||
{notChecked() ? (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
disabled={props.overview.probingEmbeddings}
|
||||
onClick={props.overview.onProbeEmbeddings}
|
||||
>
|
||||
{props.overview.probingEmbeddings
|
||||
? t("memoryPage.overview.health.testing")
|
||||
: t("memoryPage.overview.health.test")}
|
||||
</button>{" "}
|
||||
</>
|
||||
) : undefined}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{props.payload.embeddingRuntime ? (
|
||||
<SettingsRow
|
||||
title={t("memoryPage.overview.health.runtime")}
|
||||
description={props.payload.embeddingRuntime.loadError}
|
||||
control={
|
||||
<SettingsValue
|
||||
value={[
|
||||
props.payload.embeddingRuntime.engine,
|
||||
props.payload.embeddingRuntime.backend,
|
||||
props.payload.embeddingRuntime.buildInfo,
|
||||
props.payload.embeddingRuntime.model?.id,
|
||||
props.payload.embeddingRuntime.endpoints
|
||||
? Object.entries(props.payload.embeddingRuntime.endpoints)
|
||||
.map(([name, state]) => `${name}=${state}`)
|
||||
.join(" ")
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCards(props: MemoryOverviewProps) {
|
||||
const payload = createMemo(() => (props.status.kind === "ready" ? props.status.payload : null));
|
||||
return (
|
||||
<Show when={payload()}>
|
||||
{(current) => (
|
||||
<>
|
||||
<Show when={current().dreaming}>
|
||||
{(dreaming) => (
|
||||
<>
|
||||
<Schedule dreaming={dreaming()} />
|
||||
<Activity dreaming={dreaming()} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
{current().searchRuntimeRegistered !== false && (
|
||||
<EngineHealth payload={current()} overview={props} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcuts(props: MemoryOverviewProps) {
|
||||
return (
|
||||
<SettingsSection title={t("memoryPage.overview.shortcuts.title")}>
|
||||
<For
|
||||
each={
|
||||
[
|
||||
["memories", "memoryPage.overview.shortcuts.memories"],
|
||||
["dreams", "memoryPage.overview.shortcuts.diary"],
|
||||
["settings", "memoryPage.overview.shortcuts.settings"],
|
||||
] as const
|
||||
}
|
||||
keyed={(entry) => entry[0]}
|
||||
>
|
||||
{(entry) => (
|
||||
<SettingsNavRow title={t(entry()[1])} onClick={() => props.onNavigate(entry()[0])} />
|
||||
)}
|
||||
</For>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryOverview(props: MemoryOverviewProps) {
|
||||
return (
|
||||
<div class="settings-page memory-overview">
|
||||
<Hero {...props} />{" "}
|
||||
{props.engineSelection.kind !== "off" && !props.engineDisabled ? (
|
||||
<StatusCards {...props} />
|
||||
) : undefined}{" "}
|
||||
<Shortcuts {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return MemoryOverview;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"controlUi": {
|
||||
"entry": "dist/control-ui/overview/index.js",
|
||||
"activation": "settings-only",
|
||||
"settingsViews": [{"section": "memory", "panel": "overview"}]
|
||||
},
|
||||
"id": "memory-core",
|
||||
"categories": ["memory"],
|
||||
"doctorContract": {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openclaw/fs-safe": "0.26.6",
|
||||
"@solidjs/web": "2.0.0-rc.14",
|
||||
"solid-js": "2.0.0-rc.14",
|
||||
"json5": "2.2.3",
|
||||
"kysely": "0.29.6",
|
||||
"p-limit": "7.3.3",
|
||||
@@ -18,6 +20,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"@solidjs/compiler": "2.0.0-rc.14",
|
||||
"esbuild": "0.28.2",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -29,6 +33,12 @@
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"controlUi": "./browser/index.tsx",
|
||||
"assetScripts": {
|
||||
"build": "cd ../.. && node --import ./scripts/tsx.mjs scripts/build-plugin-control-ui.mts extensions/memory-core",
|
||||
"copy": "cd ../.. && node --import ./scripts/tsx.mjs scripts/build-plugin-control-ui.mts extensions/memory-core --copy",
|
||||
"buildOutputs": ["dist/openclaw.plugin.json"]
|
||||
},
|
||||
"build": {
|
||||
"workerEntries": [
|
||||
"./src/memory/manager-search.worker.ts",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/** Browser-safe wire values returned by doctor.memory.status. */
|
||||
export type DoctorMemoryEmbeddingRuntimePayload = {
|
||||
engine: "llama.cpp";
|
||||
state: "ready" | "failed";
|
||||
backend?: "metal" | "cpu";
|
||||
buildInfo?: string;
|
||||
model?: { id: string; path?: string };
|
||||
capabilities?: { vision: boolean; draft: boolean };
|
||||
endpoints?: Record<string, "ready" | "unavailable">;
|
||||
loadError?: string;
|
||||
};
|
||||
|
||||
export type DoctorMemoryDreamingEntry = {
|
||||
key: string;
|
||||
path: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
snippet: string;
|
||||
recallCount: number;
|
||||
dailyCount: number;
|
||||
groundedCount: number;
|
||||
totalSignalCount: number;
|
||||
lightHits: number;
|
||||
remHits: number;
|
||||
phaseHitCount: number;
|
||||
promotedAt?: string;
|
||||
lastRecalledAt?: string;
|
||||
};
|
||||
|
||||
type DoctorMemoryDreamingPhase = {
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
limit: number;
|
||||
managedCronPresent: boolean;
|
||||
nextRunAtMs?: number;
|
||||
};
|
||||
|
||||
export type DoctorMemoryDreamingConfig = {
|
||||
enabled: boolean;
|
||||
timezone?: string;
|
||||
verboseLogging: boolean;
|
||||
storageMode: "inline" | "separate" | "both";
|
||||
separateReports: boolean;
|
||||
phases: {
|
||||
light: DoctorMemoryDreamingPhase & { lookbackDays: number };
|
||||
deep: DoctorMemoryDreamingPhase & {
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
recencyHalfLifeDays: number;
|
||||
maxAgeDays?: number;
|
||||
};
|
||||
rem: DoctorMemoryDreamingPhase & { lookbackDays: number; minPatternStrength: number };
|
||||
};
|
||||
};
|
||||
|
||||
export type DoctorMemoryDreamingPayload = DoctorMemoryDreamingConfig & {
|
||||
shortTermCount: number;
|
||||
recallSignalCount: number;
|
||||
dailySignalCount: number;
|
||||
groundedSignalCount: number;
|
||||
totalSignalCount: number;
|
||||
phaseSignalCount: number;
|
||||
lightPhaseHitCount: number;
|
||||
remPhaseHitCount: number;
|
||||
promotedTotal: number;
|
||||
promotedToday: number;
|
||||
storePath?: string;
|
||||
phaseSignalPath?: string;
|
||||
phaseSignalError?: string;
|
||||
storeError?: string;
|
||||
lastPromotedAt?: string;
|
||||
shortTermEntries: DoctorMemoryDreamingEntry[];
|
||||
signalEntries: DoctorMemoryDreamingEntry[];
|
||||
promotedEntries: DoctorMemoryDreamingEntry[];
|
||||
};
|
||||
|
||||
export type DoctorMemoryStatusPayload = {
|
||||
agentId: string;
|
||||
searchRuntimeRegistered?: boolean;
|
||||
provider?: string;
|
||||
health?: {
|
||||
status: "ready" | "degraded" | "unavailable";
|
||||
message?: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
embedding: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
checked?: boolean;
|
||||
cached?: boolean;
|
||||
checkedAtMs?: number;
|
||||
cacheExpiresAtMs?: number;
|
||||
};
|
||||
embeddingRuntime?: DoctorMemoryEmbeddingRuntimePayload;
|
||||
dreaming?: DoctorMemoryDreamingPayload;
|
||||
};
|
||||
@@ -93,3 +93,9 @@ export type { GatewayCoreRequestParams } from "./core-request-params.js";
|
||||
export type { SessionsPatchResult } from "./sessions-patch-result.js";
|
||||
|
||||
export * from "./schema/session-processes.js";
|
||||
export type {
|
||||
DoctorMemoryStatusPayload,
|
||||
DoctorMemoryEmbeddingRuntimePayload,
|
||||
DoctorMemoryDreamingConfig,
|
||||
DoctorMemoryDreamingPayload,
|
||||
} from "./doctor-memory.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Host-owned Settings destinations with a packaged plugin presentation. */
|
||||
export type ControlUiSettingsPlacement = { section: "memory"; panel: "overview" };
|
||||
Generated
+12
@@ -1622,6 +1622,9 @@ importers:
|
||||
'@openclaw/fs-safe':
|
||||
specifier: 0.26.6
|
||||
version: 0.26.6
|
||||
'@solidjs/web':
|
||||
specifier: 2.0.0-rc.14
|
||||
version: 2.0.0-rc.14(solid-js@2.0.0-rc.14)
|
||||
json5:
|
||||
specifier: 2.2.3
|
||||
version: 2.2.3
|
||||
@@ -1631,6 +1634,9 @@ importers:
|
||||
p-limit:
|
||||
specifier: 7.3.3
|
||||
version: 7.3.3
|
||||
solid-js:
|
||||
specifier: 2.0.0-rc.14
|
||||
version: 2.0.0-rc.14
|
||||
typebox:
|
||||
specifier: 1.3.36
|
||||
version: 1.3.36
|
||||
@@ -1638,6 +1644,12 @@ importers:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
'@solidjs/compiler':
|
||||
specifier: 2.0.0-rc.14
|
||||
version: 2.0.0-rc.14
|
||||
esbuild:
|
||||
specifier: 0.28.2
|
||||
version: 0.28.2
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
@@ -1,46 +1,93 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
buildPluginControlUi,
|
||||
writePluginBuildManifest,
|
||||
} from "../src/cli/plugins-control-ui-build.js";
|
||||
import { controlUiSource } from "../src/plugins/package-manifest.js";
|
||||
import { controlUiSource, normalizeManifestControlUi } from "../src/plugins/manifest-control-ui.js";
|
||||
import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mts";
|
||||
import { collectSourceCheckoutPluginBuildEntries } from "./lib/bundled-plugin-build-entries.mjs";
|
||||
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
|
||||
import { isRecord } from "./lib/record-shared.mjs";
|
||||
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
||||
|
||||
const packageDir = process.argv[2];
|
||||
if (!packageDir) {
|
||||
throw new Error("Pass the plugin package directory to build its Control UI assets.");
|
||||
export async function buildBundledPluginControlUi(
|
||||
packageDir: string,
|
||||
options: { copy?: boolean; repoRoot?: string } = {},
|
||||
): Promise<void> {
|
||||
const rootDir = path.resolve(packageDir);
|
||||
const packageManifest = JSON.parse(await fs.readFile(path.join(rootDir, "package.json"), "utf8"));
|
||||
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
if (options.copy) {
|
||||
const generatedManifest = JSON.parse(
|
||||
await fs.readFile(path.join(rootDir, "dist/openclaw.plugin.json"), "utf8"),
|
||||
);
|
||||
const repoRoot = options.repoRoot ?? resolveRepoRoot(import.meta.url);
|
||||
const pluginDir = path.relative(path.join(repoRoot, "extensions"), rootDir);
|
||||
if (!pluginDir || pluginDir.includes(path.sep) || pluginDir.startsWith(".")) {
|
||||
throw new Error("Bundled UI copy must run inside one bundled plugin package.");
|
||||
}
|
||||
const output = path.dirname(generatedManifest.controlUi.entry);
|
||||
if (!/^dist\/control-ui\/[a-f0-9]{64}$/u.test(output)) {
|
||||
throw new Error("Build the plugin's immutable Control UI assets before copying.");
|
||||
}
|
||||
assertRealOutputRoot(path.join(repoRoot, "dist"));
|
||||
assertRealOutputRoot(path.join(repoRoot, "dist/extensions"));
|
||||
assertRealOutputRoot(path.join(repoRoot, "dist/extensions", pluginDir));
|
||||
await fs.cp(
|
||||
path.join(rootDir, output),
|
||||
path.join(repoRoot, "dist/extensions", pluginDir, output),
|
||||
{ recursive: true },
|
||||
);
|
||||
} else {
|
||||
assertRealOutputRoot(path.join(rootDir, "dist"));
|
||||
const source = controlUiSource(packageManifest);
|
||||
if (!source) {
|
||||
throw new Error("Missing package.json openclaw.controlUi browser entrypoint.");
|
||||
}
|
||||
const controlUi = await buildPluginControlUi({ rootDir, source });
|
||||
// Runtime builds own generated artifacts, never the authored source manifest.
|
||||
await writePluginBuildManifest(path.join(rootDir, "dist"), { ...manifest, controlUi });
|
||||
}
|
||||
}
|
||||
const rootDir = path.resolve(packageDir);
|
||||
const packageManifest = JSON.parse(await fs.readFile(path.join(rootDir, "package.json"), "utf8"));
|
||||
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
if (process.argv.includes("--copy")) {
|
||||
const generatedManifest = JSON.parse(
|
||||
await fs.readFile(path.join(rootDir, "dist/openclaw.plugin.json"), "utf8"),
|
||||
);
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
const pluginDir = path.relative(path.join(repoRoot, "extensions"), rootDir);
|
||||
if (!pluginDir || pluginDir.includes(path.sep) || pluginDir.startsWith(".")) {
|
||||
throw new Error("Bundled UI copy must run inside one bundled plugin package.");
|
||||
|
||||
/** UI-only commands need static Settings presenters even without a runtime build. */
|
||||
export async function prepareBundledSettingsControlUi(repoRoot: string): Promise<void> {
|
||||
const selected: string[] = [];
|
||||
for (const entry of collectSourceCheckoutPluginBuildEntries({ cwd: repoRoot })) {
|
||||
if (entry.isolated || !isRecord(entry.packageJson) || !controlUiSource(entry.packageJson)) {
|
||||
continue;
|
||||
}
|
||||
const rootDir = path.join(repoRoot, "extensions", entry.id);
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(rootDir, "openclaw.plugin.json"), "utf8"),
|
||||
);
|
||||
const declaration = normalizeManifestControlUi(manifest.controlUi);
|
||||
if (!declaration.ok) {
|
||||
throw new Error(`Invalid Control UI declaration for ${entry.id}: ${declaration.error}`);
|
||||
}
|
||||
if (!declaration.value?.settingsViews?.length) {
|
||||
continue;
|
||||
}
|
||||
await buildBundledPluginControlUi(rootDir);
|
||||
await buildBundledPluginControlUi(rootDir, { copy: true, repoRoot });
|
||||
selected.push(entry.id);
|
||||
}
|
||||
const output = path.dirname(generatedManifest.controlUi.entry);
|
||||
if (!/^dist\/control-ui\/[a-f0-9]{64}$/u.test(output)) {
|
||||
throw new Error("Build the plugin's immutable Control UI assets before copying.");
|
||||
if (selected.length) {
|
||||
copyBundledPluginMetadata({ repoRoot, pluginIds: new Set(selected) });
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
const packageDir = process.argv[2];
|
||||
if (!packageDir) {
|
||||
throw new Error("Pass the plugin package directory to build its Control UI assets.");
|
||||
}
|
||||
if (packageDir === "--prepare-settings") {
|
||||
await prepareBundledSettingsControlUi(path.resolve(process.argv[3] ?? "."));
|
||||
} else {
|
||||
await buildBundledPluginControlUi(packageDir, { copy: process.argv.includes("--copy") });
|
||||
}
|
||||
await fs.cp(
|
||||
path.join(rootDir, output),
|
||||
path.join(repoRoot, "dist/extensions", pluginDir, output),
|
||||
{ recursive: true },
|
||||
);
|
||||
} else {
|
||||
assertRealOutputRoot(path.join(rootDir, "dist"));
|
||||
const source = controlUiSource(packageManifest);
|
||||
if (!source) {
|
||||
throw new Error("Missing package.json openclaw.controlUi browser entrypoint.");
|
||||
}
|
||||
const controlUi = await buildPluginControlUi({ rootDir, source });
|
||||
// Runtime builds own generated artifacts, never the authored source manifest.
|
||||
await writePluginBuildManifest(path.join(rootDir, "dist"), { ...manifest, controlUi });
|
||||
}
|
||||
@@ -38,7 +38,13 @@ const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills";
|
||||
const TRANSIENT_COPY_ERROR_CODES = new Set(["EEXIST", "ENOENT", "ENOTEMPTY", "EBUSY"]);
|
||||
const COPY_RETRY_DELAYS_MS = [10, 25, 50];
|
||||
|
||||
type CopyMetadataParams = { cwd?: string; repoRoot?: string; env?: NodeJS.ProcessEnv };
|
||||
type CopyMetadataParams = {
|
||||
cwd?: string;
|
||||
repoRoot?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Additive preparation: leave every unselected distribution untouched. */
|
||||
pluginIds?: ReadonlySet<string>;
|
||||
};
|
||||
type SkillPathParams = {
|
||||
distPluginDir: string;
|
||||
manifest: Record<string, unknown>;
|
||||
@@ -305,7 +311,7 @@ function copyMetadata(params: CopyMetadataParams): void {
|
||||
const generatedChannelConfigsByPlugin = readGeneratedBundledChannelConfigs(repoRoot);
|
||||
const sourcePluginDirs = new Set<string>(["node_modules"]);
|
||||
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
if (!dirent.isDirectory() || (params.pluginIds && !params.pluginIds.has(dirent.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -437,7 +443,7 @@ function copyMetadata(params: CopyMetadataParams): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(distExtensionsRoot)) {
|
||||
if (params.pluginIds || !fs.existsSync(distExtensionsRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
/** Keep the alias-aware native builder outside Vite's configuration bundle. */
|
||||
export async function prepareControlUiSettings(repoRoot: string): Promise<void> {
|
||||
if (!fs.existsSync(path.join(repoRoot, "extensions"))) {
|
||||
return;
|
||||
}
|
||||
const scripts = path.resolve(import.meta.dirname, "..");
|
||||
await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
path.join(scripts, "tsx.mjs"),
|
||||
path.join(scripts, "build-plugin-control-ui.mts"),
|
||||
"--prepare-settings",
|
||||
repoRoot,
|
||||
],
|
||||
{ cwd: repoRoot, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
}
|
||||
@@ -280,7 +280,9 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// approved +18 exports / +6 callables scope (2026-09-26, #157148 and #157225).
|
||||
// +1: approved boot-selected family lookup on the database-engine runtime.
|
||||
// +1: shared auth-patch application replaces manual plugin config merging.
|
||||
3792,
|
||||
// +6: packaged Settings placement, snapshot, components, context, view, and
|
||||
// DoctorMemoryStatusPayload for the plugin-owned Memory overview.
|
||||
3798,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CONTROL_UI_BUILD_PREFIX,
|
||||
controlUiBuildSiblingPid,
|
||||
} from "./lib/output-root-guard.mjs";
|
||||
import { prepareControlUiSettings } from "./lib/prepare-control-ui-settings.mts";
|
||||
import { isRecord } from "./lib/record-shared.mjs";
|
||||
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mts";
|
||||
import { resolveNodePackageBin } from "./run-node-package-bin.mts";
|
||||
@@ -386,6 +387,9 @@ async function buildAndPublishUi(
|
||||
toolCall: UiSpawnCall,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<UiSpawnResult> {
|
||||
// Prepare before cache input capture: presenter source edits must change the
|
||||
// canonical bytes and inventory even when the UI bundle can otherwise be reused.
|
||||
await prepareControlUiSettings(repoRoot);
|
||||
const dist = path.join(repoRoot, "dist");
|
||||
const output = path.join(dist, "control-ui");
|
||||
fs.mkdirSync(dist, { recursive: true });
|
||||
|
||||
@@ -93,7 +93,7 @@ console.log(JSON.stringify({ mode: (await fs.stat(manifest)).mode & 0o7777, cont
|
||||
});
|
||||
|
||||
describe("native plugin browser builds", () => {
|
||||
it.each([true, false])(
|
||||
it.each([true, false, "settings-only"])(
|
||||
"builds runtime browser artifacts without changing authored plugin metadata (authored UI: %s)",
|
||||
async (authoredUi) => {
|
||||
const project = await fixture();
|
||||
@@ -106,7 +106,19 @@ describe("native plugin browser builds", () => {
|
||||
id: "fixture",
|
||||
configSchema: { type: "object", properties: { token: { type: "string" } } },
|
||||
activation: { onStartup: true },
|
||||
...(authoredUi ? { controlUi: { entry: "dist/control-ui/authored/index.js" } } : {}),
|
||||
...(authoredUi
|
||||
? {
|
||||
controlUi: {
|
||||
entry: "dist/control-ui/authored/index.js",
|
||||
...(authoredUi === "settings-only"
|
||||
? {
|
||||
activation: "settings-only",
|
||||
settingsViews: [{ section: "memory", panel: "overview" }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const original = JSON.stringify(authored, null, 2);
|
||||
await fs.writeFile(manifestPath, original);
|
||||
@@ -123,6 +135,13 @@ describe("native plugin browser builds", () => {
|
||||
expect(await fs.readFile(manifestPath, "utf8")).toBe(original);
|
||||
const generatedPath = path.join(project.rootDir, "dist/openclaw.plugin.json");
|
||||
const generated = JSON.parse(await fs.readFile(generatedPath, "utf8"));
|
||||
expect(generated.controlUi.activation).toBe(
|
||||
authoredUi === "settings-only" ? "settings-only" : undefined,
|
||||
);
|
||||
expect(generated.controlUi.settingsViews).toEqual(
|
||||
authoredUi === "settings-only" ? [{ section: "memory", panel: "overview" }] : undefined,
|
||||
);
|
||||
expect(await buildPluginControlUi({ ...project, check: true })).toEqual(generated.controlUi);
|
||||
generated.id = "previous-name";
|
||||
generated.configSchema = { type: "object", properties: {} };
|
||||
generated.activation = { onStartup: false };
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isPluginControlUiAssetPath,
|
||||
pluginControlUiGenerationHash,
|
||||
} from "../plugins/control-ui-assets.js";
|
||||
import { normalizeManifestControlUi } from "../plugins/manifest-control-ui.js";
|
||||
import type { PluginManifestControlUi } from "../plugins/manifest-types.js";
|
||||
import { PLUGIN_MANIFEST_FILENAME } from "../plugins/manifest.js";
|
||||
import { buildPluginLoaderAliasMap } from "../plugins/sdk-alias.js";
|
||||
@@ -40,6 +41,21 @@ export async function buildPluginControlUi(params: {
|
||||
check?: boolean;
|
||||
}): Promise<PluginManifestControlUi> {
|
||||
const rootDir = await fs.realpath(params.rootDir);
|
||||
const manifestText = await fs
|
||||
.readFile(path.join(rootDir, PLUGIN_MANIFEST_FILENAME), "utf8")
|
||||
.catch((error: unknown) => {
|
||||
if (isRecord(error) && error.code === "ENOENT") {
|
||||
return "{}";
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const authored: unknown = JSON.parse(manifestText);
|
||||
const presentation = normalizeManifestControlUi(
|
||||
isRecord(authored) ? authored.controlUi : undefined,
|
||||
);
|
||||
if (!presentation.ok) {
|
||||
throw new Error(presentation.error);
|
||||
}
|
||||
const entry = await fs.realpath(path.resolve(rootDir, params.source));
|
||||
const relative = path.relative(rootDir, entry);
|
||||
if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
|
||||
@@ -80,6 +96,10 @@ export async function buildPluginControlUi(params: {
|
||||
const output = `dist/control-ui/${pluginControlUiGenerationHash(files)}`;
|
||||
const outputDir = path.join(rootDir, output);
|
||||
const declaration = {
|
||||
...(presentation.value?.activation ? { activation: presentation.value.activation } : {}),
|
||||
...(presentation.value?.settingsViews
|
||||
? { settingsViews: presentation.value.settingsViews }
|
||||
: {}),
|
||||
entry: `${output}/index.js`,
|
||||
...(files.some((file) => file.name === "index.css") ? { styles: [`${output}/index.css`] } : {}),
|
||||
};
|
||||
|
||||
@@ -129,6 +129,20 @@ describe("native Control UI browser assets", () => {
|
||||
await import("./control-ui.js");
|
||||
});
|
||||
|
||||
it("omits settings-only presentations from activation while keeping ordinary plugins", async () => {
|
||||
const fixture = activateFixture();
|
||||
expect((await listControlUiPluginCatalog()).plugins.map((plugin) => plugin.pluginId)).toEqual([
|
||||
"native-ui",
|
||||
]);
|
||||
fixture.record.controlUi = {
|
||||
...fixture.record.controlUi!,
|
||||
activation: "settings-only",
|
||||
settingsViews: [{ section: "memory", panel: "overview" }],
|
||||
};
|
||||
expect((await listControlUiPluginCatalog()).plugins).toEqual([]);
|
||||
expect(listControlUiPluginActivations({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("hot-applies Custom plugin UI admission without replacing the backend plugin", async () => {
|
||||
await withTempConfig({
|
||||
cfg: {},
|
||||
|
||||
@@ -87,7 +87,13 @@ function pluginState(record: PluginRecord): BrowserPluginState {
|
||||
|
||||
function browserOwners(registry: PluginRegistry): PluginRecord[] {
|
||||
return registry.plugins
|
||||
.filter((record) => record.enabled && record.status === "loaded" && record.controlUi)
|
||||
.filter(
|
||||
(record) =>
|
||||
record.enabled &&
|
||||
record.status === "loaded" &&
|
||||
record.controlUi &&
|
||||
record.controlUi.activation !== "settings-only",
|
||||
)
|
||||
.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type {
|
||||
DoctorMemoryDreamingConfig,
|
||||
DoctorMemoryEmbeddingRuntimePayload,
|
||||
DoctorMemoryStatusPayload,
|
||||
} from "../../../packages/gateway-protocol/src/doctor-memory.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { formatErrorMessage as formatError } from "../../infra/errors.js";
|
||||
@@ -18,7 +23,6 @@ import {
|
||||
} from "../../memory-host-sdk/dreaming.js";
|
||||
import { readMemorySearchManagerStatus } from "../../memory-host-sdk/host/types.js";
|
||||
import * as defaultMemoryCoreRuntime from "../../plugin-sdk/memory-core-bundled-runtime.js";
|
||||
import type { MemoryHealth } from "../../plugins/memory-provider-types.js";
|
||||
import {
|
||||
getActiveMemorySearchManagerCore,
|
||||
resolveActiveMemoryBackendConfig,
|
||||
@@ -40,6 +44,11 @@ import {
|
||||
} from "./doctor-memory-target.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export type {
|
||||
DoctorMemoryStatusPayload,
|
||||
DoctorMemoryEmbeddingRuntimePayload,
|
||||
} from "../../../packages/gateway-protocol/src/doctor-memory.js";
|
||||
|
||||
export type { DoctorMemoryDreamDiaryPayload } from "./doctor-memory-files.js";
|
||||
|
||||
type DoctorMemoryCoreRuntime = Pick<
|
||||
@@ -59,36 +68,6 @@ type DreamingStoreStats = Omit<ShortTermDreamingStats, "storePath" | "phaseSigna
|
||||
storeError?: string;
|
||||
};
|
||||
|
||||
type DoctorMemoryDreamingPayload = ReturnType<typeof resolveDreamingConfig> & DreamingStoreStats;
|
||||
|
||||
export type DoctorMemoryStatusPayload = {
|
||||
agentId: string;
|
||||
searchRuntimeRegistered?: boolean;
|
||||
provider?: string;
|
||||
health?: MemoryHealth;
|
||||
embedding: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
checked?: boolean;
|
||||
cached?: boolean;
|
||||
checkedAtMs?: number;
|
||||
cacheExpiresAtMs?: number;
|
||||
};
|
||||
embeddingRuntime?: DoctorMemoryEmbeddingRuntimePayload;
|
||||
dreaming?: DoctorMemoryDreamingPayload;
|
||||
};
|
||||
|
||||
export type DoctorMemoryEmbeddingRuntimePayload = {
|
||||
engine: "llama.cpp";
|
||||
state: "ready" | "failed";
|
||||
backend?: "metal" | "cpu";
|
||||
buildInfo?: string;
|
||||
model?: { id: string; path?: string };
|
||||
capabilities?: { vision: boolean; draft: boolean };
|
||||
endpoints?: Record<string, "ready" | "unavailable">;
|
||||
loadError?: string;
|
||||
};
|
||||
|
||||
export type { DoctorMemoryDreamActionPayload } from "./doctor-memory-target.js";
|
||||
|
||||
function extractIsoDayFromPath(filePath: string): string | null {
|
||||
@@ -107,7 +86,7 @@ function groundedMarkdownToDiaryLines(markdown: string): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDreamingConfig(cfg: OpenClawConfig) {
|
||||
function resolveDreamingConfig(cfg: OpenClawConfig): DoctorMemoryDreamingConfig {
|
||||
const resolved = resolveMemoryDreamingConfig({
|
||||
pluginConfig: resolveMemoryDreamingPluginConfig(cfg),
|
||||
cfg,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ControlUiSettingsPlacement } from "../../packages/gateway-protocol/src/plugin-control-ui-settings.js";
|
||||
import type { ControlUiComponentHandle } from "./control-ui-components.js";
|
||||
|
||||
export type { ControlUiSettingsPlacement };
|
||||
export type ControlUiSettingsSnapshot = {
|
||||
readonly agentId: string | null;
|
||||
readonly agentsLoading: boolean;
|
||||
readonly agentsError: string | null;
|
||||
readonly engine: {
|
||||
readonly kind: "default" | "off" | "pinned";
|
||||
readonly id: string | null;
|
||||
readonly availability: "enabled" | "disabled" | "missing" | "loading" | "unknown";
|
||||
};
|
||||
readonly connected: boolean;
|
||||
readonly connectionEpoch: number;
|
||||
readonly routeIntent: string;
|
||||
readonly locale: string;
|
||||
};
|
||||
|
||||
type Content = string | HTMLElement;
|
||||
type SectionProps = { title: string; content: HTMLElement };
|
||||
type RowProps = { title: string; description?: Content; control?: Content };
|
||||
type StatusProps = { kind: "ok" | "warn" | "danger" | "accent" | "muted"; label: string };
|
||||
type ValueProps = { value: string | number; mono?: boolean };
|
||||
type NavProps = { title: string; onClick: () => void };
|
||||
type PetProps = {
|
||||
seed: string;
|
||||
sleeping?: boolean;
|
||||
reading?: boolean;
|
||||
grumpy?: boolean;
|
||||
standalone?: boolean;
|
||||
};
|
||||
|
||||
/** Consumed by Settings presentations; the shipped ControlUiComponents contract is unchanged. */
|
||||
export type ControlUiSettingsComponents = {
|
||||
mountSection: (
|
||||
container: HTMLElement,
|
||||
props: SectionProps,
|
||||
) => ControlUiComponentHandle<SectionProps>;
|
||||
mountRow: (container: HTMLElement, props: RowProps) => ControlUiComponentHandle<RowProps>;
|
||||
mountStatus: (
|
||||
container: HTMLElement,
|
||||
props: StatusProps,
|
||||
) => ControlUiComponentHandle<StatusProps>;
|
||||
mountValue: (container: HTMLElement, props: ValueProps) => ControlUiComponentHandle<ValueProps>;
|
||||
mountNavigation: (container: HTMLElement, props: NavProps) => ControlUiComponentHandle<NavProps>;
|
||||
mountLobster: (container: HTMLElement, props: PetProps) => ControlUiComponentHandle<PetProps>;
|
||||
};
|
||||
|
||||
export type ControlUiSettingsContext = {
|
||||
readonly signal: AbortSignal;
|
||||
readonly props: ControlUiSettingsSnapshot;
|
||||
readonly components: ControlUiSettingsComponents;
|
||||
/** Existing authenticated operator RPC authority, bound to this Settings lifetime. */
|
||||
request: <T = unknown>(method: string, params?: unknown) => Promise<T>;
|
||||
navigate: (destination: "overview" | "memories" | "dreams" | "settings") => void;
|
||||
refreshAgents: () => void;
|
||||
formatRelativeTimestamp: (timestamp: number) => string;
|
||||
formatError: (error: unknown) => string;
|
||||
};
|
||||
|
||||
export type ControlUiSettingsView = {
|
||||
readonly placement: ControlUiSettingsPlacement;
|
||||
mount: (
|
||||
container: HTMLElement,
|
||||
context: ControlUiSettingsContext,
|
||||
) => {
|
||||
update: (props: ControlUiSettingsSnapshot) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
};
|
||||
@@ -310,3 +310,11 @@ export type ControlUiPlugin = {
|
||||
export function defineControlUiPlugin(plugin: ControlUiPlugin): ControlUiPlugin {
|
||||
return plugin;
|
||||
}
|
||||
export type { DoctorMemoryStatusPayload } from "../../packages/gateway-protocol/src/doctor-memory.js";
|
||||
export type {
|
||||
ControlUiSettingsPlacement,
|
||||
ControlUiSettingsSnapshot,
|
||||
ControlUiSettingsComponents,
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsView,
|
||||
} from "./control-ui-settings.js";
|
||||
@@ -54,6 +54,18 @@ describe("native Control UI manifest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves settings-only presentation metadata through manifest discovery", () => {
|
||||
const controlUi = {
|
||||
entry: "dist/control-ui/index.js",
|
||||
activation: "settings-only",
|
||||
settingsViews: [{ section: "memory", panel: "overview" }],
|
||||
};
|
||||
expect(loadPluginManifest(fixture(controlUi).dir)).toMatchObject({
|
||||
ok: true,
|
||||
manifest: { controlUi },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ declaration: undefined, expected: undefined },
|
||||
{ declaration: [], expected: [] },
|
||||
@@ -111,6 +123,16 @@ describe("native Control UI manifest", () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ entry: "dist/control-ui/index.js", activation: "settings-only" },
|
||||
{ entry: "dist/control-ui/index.js", activation: "on-startup" },
|
||||
{ entry: "dist/control-ui/index.js", settingsViews: [{ section: "memory", panel: "search" }] },
|
||||
{
|
||||
entry: "dist/control-ui/index.js",
|
||||
settingsViews: [
|
||||
{ section: "memory", panel: "overview" },
|
||||
{ section: "memory", panel: "overview" },
|
||||
],
|
||||
},
|
||||
{ entry: "src/index.ts" },
|
||||
{ entry: "dist/index.js" },
|
||||
{ entry: "../dist/control-ui/index.js" },
|
||||
|
||||
@@ -22,8 +22,30 @@ export function normalizeManifestControlUi(
|
||||
if (value === undefined) {
|
||||
return ok(undefined);
|
||||
}
|
||||
if (!isRecord(value) || Object.keys(value).some((key) => key !== "entry" && key !== "styles")) {
|
||||
return err("controlUi must contain only entry and optional styles");
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
Object.keys(value).some(
|
||||
(key) => !["entry", "styles", "activation", "settingsViews"].includes(key),
|
||||
)
|
||||
) {
|
||||
return err("controlUi contains an unknown field");
|
||||
}
|
||||
if (value.activation !== undefined && value.activation !== "settings-only") {
|
||||
return err("controlUi.activation must be settings-only when specified");
|
||||
}
|
||||
if (
|
||||
value.settingsViews !== undefined &&
|
||||
(!Array.isArray(value.settingsViews) ||
|
||||
value.settingsViews.length !== 1 ||
|
||||
!isRecord(value.settingsViews[0]) ||
|
||||
Object.keys(value.settingsViews[0]).length !== 2 ||
|
||||
value.settingsViews[0].section !== "memory" ||
|
||||
value.settingsViews[0].panel !== "overview")
|
||||
) {
|
||||
return err("controlUi.settingsViews must declare the Memory overview placement once");
|
||||
}
|
||||
if (value.activation === "settings-only" && !value.settingsViews) {
|
||||
return err("settings-only Control UI requires a Settings placement");
|
||||
}
|
||||
const entry = typeof value.entry === "string" ? value.entry.replace(/^\.\//u, "") : "";
|
||||
// A dedicated built directory prevents a declaration from publishing package sources.
|
||||
@@ -49,5 +71,10 @@ export function normalizeManifestControlUi(
|
||||
styles.push(style);
|
||||
}
|
||||
}
|
||||
return ok({ entry, ...(styles.length > 0 ? { styles } : {}) });
|
||||
return ok({
|
||||
entry,
|
||||
...(styles.length > 0 ? { styles } : {}),
|
||||
...(value.activation === "settings-only" ? { activation: value.activation } : {}),
|
||||
...(value.settingsViews ? { settingsViews: [{ section: "memory", panel: "overview" }] } : {}),
|
||||
});
|
||||
}
|
||||
@@ -326,6 +326,9 @@ export type PluginManifestControlUi = {
|
||||
entry: string;
|
||||
/** Stylesheets in the same asset directory, loaded before activation. */
|
||||
styles?: string[];
|
||||
/** Omission retains ordinary runtime activation. */
|
||||
activation?: "settings-only";
|
||||
settingsViews?: import("../../packages/gateway-protocol/src/plugin-control-ui-settings.js").ControlUiSettingsPlacement[];
|
||||
};
|
||||
|
||||
export type PluginManifestMcpServer = Record<string, unknown>;
|
||||
|
||||
@@ -56,6 +56,7 @@ function copyUiFixture(root: string): void {
|
||||
"scripts/lib/build-identity.mts",
|
||||
"scripts/lib/output-root-guard.mjs",
|
||||
"scripts/lib/record-shared.mjs",
|
||||
"scripts/lib/prepare-control-ui-settings.mts",
|
||||
"src/infra/process-env.ts",
|
||||
"src/infra/windows-process-start.ts",
|
||||
"src/shared/freebsd-process-identity.ts",
|
||||
@@ -413,6 +414,10 @@ process.exitCode = ${expectedExit};\n`,
|
||||
"junction",
|
||||
);
|
||||
fs.mkdirSync(path.join(root, "extensions"));
|
||||
// This fixture owns UI cache behavior; the native presenter builder is
|
||||
// exercised with real source and distribution bytes in its delivery suite.
|
||||
fs.writeFileSync(path.join(root, "scripts/tsx.mjs"), "export {};\n");
|
||||
fs.writeFileSync(path.join(root, "scripts/build-plugin-control-ui.mts"), "export {};\n");
|
||||
writeUiPackageBin(modules, "dompurify", "export {};\n");
|
||||
writeUiPackageBin(
|
||||
modules,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"src/plugin-sdk/control-ui.ts",
|
||||
"src/plugin-sdk/control-ui-components.ts",
|
||||
"src/plugin-sdk/control-ui-session-link.ts",
|
||||
"src/plugin-sdk/control-ui-settings.ts",
|
||||
"test/**"
|
||||
]
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import { validatePluginUiCapabilities } from "../../packages/gateway-protocol/sr
|
||||
import { isRecord } from "../../packages/normalization-core/src/record-coerce.js";
|
||||
import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js";
|
||||
import { collectSourceCheckoutPluginBuildEntries } from "../../scripts/lib/bundled-plugin-build-entries.mjs";
|
||||
import { prepareControlUiSettings } from "../../scripts/lib/prepare-control-ui-settings.mts";
|
||||
import {
|
||||
pluginControlUiGenerationHash,
|
||||
pluginControlUiRevision,
|
||||
readPluginControlUiAssets,
|
||||
type PluginControlUiAsset,
|
||||
} from "../../src/plugins/control-ui-assets.js";
|
||||
import {
|
||||
controlUiSource,
|
||||
@@ -22,10 +24,30 @@ export function controlUiPluginDistributionsPlugin(repoRoot: string, inputs: Set
|
||||
path.join(repoRoot, "ui/src/plugins/control-ui-distribution.ts"),
|
||||
);
|
||||
let source = "";
|
||||
let serving = false;
|
||||
let base = "/";
|
||||
const servedAssets = new Map<string, PluginControlUiAsset>();
|
||||
return {
|
||||
name: "control-ui-plugin-distributions",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
serving = config.command === "serve";
|
||||
base = config.base === "./" ? "/" : config.base;
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const asset = servedAssets.get((req.url ?? "").split("?")[0]!);
|
||||
if (!asset || (req.method !== "GET" && req.method !== "HEAD")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", asset.contentType);
|
||||
res.setHeader("Content-Length", asset.body.length);
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.end(req.method === "HEAD" ? undefined : asset.body);
|
||||
});
|
||||
},
|
||||
async buildStart() {
|
||||
await prepareControlUiSettings(repoRoot);
|
||||
const directory = path.join(repoRoot, "dist/extensions");
|
||||
const required = process.env.OPENCLAW_CONTROL_UI_REQUIRE_PLUGIN_DISTRIBUTIONS === "1";
|
||||
const promised = required
|
||||
@@ -97,12 +119,15 @@ export function controlUiPluginDistributionsPlugin(repoRoot: string, inputs: Set
|
||||
const urls = new Map<string, string>();
|
||||
for (const [name, asset] of build.assets) {
|
||||
consume(path.join(rootDir, build.directory, name));
|
||||
const ref = this.emitFile({
|
||||
type: "asset",
|
||||
fileName: `assets/plugins/${encodeURIComponent(pluginId)}/${revision}/${name}`,
|
||||
source: asset.body,
|
||||
});
|
||||
urls.set(name, `import.meta.ROLLUP_FILE_URL_${ref}`);
|
||||
const fileName = `assets/plugins/${encodeURIComponent(pluginId)}/${revision}/${name}`;
|
||||
if (serving) {
|
||||
const url = `${base}${fileName}`;
|
||||
servedAssets.set(url, asset);
|
||||
urls.set(name, JSON.stringify(url));
|
||||
} else {
|
||||
const ref = this.emitFile({ type: "asset", fileName, source: asset.body });
|
||||
urls.set(name, `import.meta.ROLLUP_FILE_URL_${ref}`);
|
||||
}
|
||||
}
|
||||
const metadata: Pick<
|
||||
ControlUiPluginDistribution,
|
||||
@@ -113,7 +138,7 @@ export function controlUiPluginDistributionsPlugin(repoRoot: string, inputs: Set
|
||||
bundledDistribution: { packageName: pkg.name, packageVersion: pkg.version },
|
||||
};
|
||||
modules.push(
|
||||
`{...${JSON.stringify(metadata)},entry:${urls.get(build.entryName)},styles:[${build.styles.map((name) => urls.get(name)).join(",")}],imports:[]}`,
|
||||
`{...${JSON.stringify({ ...metadata, activation: controlUi.activation, settingsViews: controlUi.settingsViews })},entry:${urls.get(build.entryName)},styles:[${build.styles.map((name) => urls.get(name)).join(",")}],imports:[]}`,
|
||||
);
|
||||
captured.add(id);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { brotliDecompressSync, gunzipSync } from "node:zlib";
|
||||
import { build, loadConfigFromFile, type Plugin } from "vite";
|
||||
import { build, createServer, loadConfigFromFile, type Plugin } from "vite";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { controlUiPluginDistributionsPlugin } from "../../config/control-ui-plugin-distributions.ts";
|
||||
|
||||
@@ -159,3 +159,92 @@ it("rejects a full build whose promised packaged plugin is absent", async () =>
|
||||
"Missing packaged native UI assets for workboard",
|
||||
);
|
||||
});
|
||||
|
||||
it("serves the same validated generation and inventory from the development server", async () => {
|
||||
const { files } = await fixture();
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: false,
|
||||
publicDir: false,
|
||||
logLevel: "silent",
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
plugins: [controlUiPluginDistributionsPlugin(root, new Set())],
|
||||
});
|
||||
try {
|
||||
await server.listen();
|
||||
const url = server.resolvedUrls!.local[0]!;
|
||||
const inventory = await (
|
||||
await fetch(new URL("ui/src/plugins/control-ui-distribution.ts", url))
|
||||
).text();
|
||||
const prefix = inventory.match(/\/assets\/plugins\/workboard\/[a-f0-9]{64}\//u)?.[0];
|
||||
expect(prefix).toBe(
|
||||
"/assets/plugins/workboard/c81749b6c5f6b8233fad01698a3bfa483e5b27dbc540f8d1a4d3117bf05e1b75/",
|
||||
);
|
||||
for (const [name, body] of files) {
|
||||
const response = await fetch(new URL(`${prefix}${name}`, url));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
name.endsWith(".css") ? "text/css" : "javascript",
|
||||
);
|
||||
expect(await response.text()).toBe(body);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("prepares source Settings assets for a UI-only build and preserves sibling distributions", async () => {
|
||||
const { pluginRoot, files, generation } = await fixture();
|
||||
const settingsRoot = path.join(root, "extensions/settings-fixture");
|
||||
await fs.mkdir(settingsRoot, { recursive: true });
|
||||
const manifestOnly = path.join(root, "extensions/manifest-only");
|
||||
await fs.mkdir(manifestOnly);
|
||||
await fs.writeFile(path.join(manifestOnly, "openclaw.plugin.json"), '{"id":"manifest-only"}');
|
||||
await fs.symlink(path.resolve("node_modules"), path.join(root, "node_modules"), "junction");
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", type: "module" }),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(settingsRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/settings-fixture",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
openclaw: { extensions: ["./index.ts"], controlUi: "./browser.ts" },
|
||||
}),
|
||||
);
|
||||
const placement = { section: "memory", panel: "overview" };
|
||||
const manifest = JSON.stringify({
|
||||
id: "settings-fixture",
|
||||
controlUi: {
|
||||
activation: "settings-only",
|
||||
entry: "dist/control-ui/source/index.js",
|
||||
settingsViews: [placement],
|
||||
},
|
||||
});
|
||||
await fs.writeFile(path.join(settingsRoot, "openclaw.plugin.json"), manifest);
|
||||
await fs.writeFile(path.join(settingsRoot, "index.ts"), "export default {};\n");
|
||||
await fs.writeFile(
|
||||
path.join(settingsRoot, "browser.ts"),
|
||||
'export default { pluginId: "settings-fixture", settingsViews: [] };\n',
|
||||
);
|
||||
await compile(new Set());
|
||||
const packaged = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(root, "dist/extensions/settings-fixture/openclaw.plugin.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
expect(packaged.controlUi.settingsViews).toEqual([placement]);
|
||||
expect(packaged.controlUi.activation).toBe("settings-only");
|
||||
const revisions = await fs.readdir(path.join(root, "ui-output/assets/plugins/settings-fixture"));
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(await fs.readFile(path.join(settingsRoot, "openclaw.plugin.json"), "utf8")).toBe(manifest);
|
||||
for (const [name, body] of files) {
|
||||
expect(await fs.readFile(path.join(pluginRoot, generation, name), "utf8")).toBe(body);
|
||||
}
|
||||
expect(JSON.parse(await fs.readFile(path.join(pluginRoot, "package.json"), "utf8")).name).toBe(
|
||||
"@openclaw/workboard",
|
||||
);
|
||||
});
|
||||
@@ -346,7 +346,10 @@ describe("Control UI Vite build", () => {
|
||||
if (!message.includes("Control UI precompression")) {
|
||||
return;
|
||||
}
|
||||
const emitted = fsSync.readdirSync(path.join(outDir, "assets"));
|
||||
const emitted = fsSync.readdirSync(path.join(outDir, "assets"), {
|
||||
encoding: "utf8",
|
||||
recursive: true,
|
||||
});
|
||||
activity.push({
|
||||
message,
|
||||
elapsed: clockMs,
|
||||
@@ -366,7 +369,7 @@ describe("Control UI Vite build", () => {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: `assets/extra-${index}.txt`,
|
||||
fileName: `assets/nested/extra-${index}.txt`,
|
||||
source: "fixture",
|
||||
});
|
||||
}
|
||||
@@ -376,7 +379,10 @@ describe("Control UI Vite build", () => {
|
||||
|
||||
await build(config);
|
||||
|
||||
const emitted = await fs.readdir(path.join(outDir, "assets"));
|
||||
const emitted = await fs.readdir(path.join(outDir, "assets"), {
|
||||
encoding: "utf8",
|
||||
recursive: true,
|
||||
});
|
||||
const completed = emitted.filter((name) => name.endsWith(".gz")).length;
|
||||
expect(completed).toBeGreaterThan(4);
|
||||
expect(
|
||||
|
||||
@@ -560,7 +560,7 @@ suite.define(() => {
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await page.clock.runFor(50);
|
||||
expect((await gateway.getRequests("sessions.catalog.list")).length).toBe(catalogRequestCount);
|
||||
await page.clock.fastForward(30_000);
|
||||
await page.clock.fastForward(120_000);
|
||||
await page.clock.runFor(100);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
defaultControlUiFeatureMethods,
|
||||
installMockGateway,
|
||||
pauseVirtualClock,
|
||||
waitForControlUiRoute,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { TEST_LINK_READER } from "../test-helpers/link-reader.ts";
|
||||
import { controlUiE2eBuiltModuleRequest } from "./control-ui-built-module.test-support.ts";
|
||||
@@ -221,22 +222,29 @@ suite.define(() => {
|
||||
await page.route(hovercardModule, (route) => route.abort());
|
||||
const gateway = await installMockGateway(page, {
|
||||
automaticallyFetchFavicons: true,
|
||||
historyMessages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: '[Field guide](https://example.com/field-guide "Guide details")',
|
||||
timestamp: 2000,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto(suite.server.baseUrl + "chat");
|
||||
const link = page.getByRole("link", { name: "Field guide", exact: true });
|
||||
// Chat mounts this runtime eagerly; About retains the optional shell provider.
|
||||
await page.goto(suite.server.baseUrl + "settings/about");
|
||||
await waitForControlUiRoute(page, { routeId: "about" });
|
||||
const link = page.locator(
|
||||
'openclaw-about-page .about-hero__link[href="https://docs.openclaw.ai"]',
|
||||
);
|
||||
await link.waitFor({ state: "visible" });
|
||||
await link.evaluate((anchor) => anchor.setAttribute("title", "Guide details"));
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => customElements.get("openclaw-link-reader-hovercard-provider") === undefined,
|
||||
),
|
||||
).toBe(true);
|
||||
// A missing hashed chunk reloads the page; wait before hovering the new document.
|
||||
await Promise.all([
|
||||
page.waitForEvent("requestfailed", (request) => hovercardModule.test(request.url())),
|
||||
page.waitForEvent("domcontentloaded"),
|
||||
link.hover(),
|
||||
]);
|
||||
await waitForControlUiRoute(page, { routeId: "about" });
|
||||
await link.waitFor({ state: "visible" });
|
||||
await link.evaluate((anchor) => anchor.setAttribute("title", "Guide details"));
|
||||
await page.mouse.move(0, 0);
|
||||
await link.hover();
|
||||
await page.locator(".tooltip-content").filter({ hasText: "Guide details" }).waitFor();
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect } from "playwright/test";
|
||||
import { it } from "vitest";
|
||||
import { takeControlUiScreenshotFrame } from "../test-helpers/control-ui-e2e-screenshot.ts";
|
||||
import {
|
||||
installMockGateway,
|
||||
startControlUiE2eServer,
|
||||
waitForControlUiRoute,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({ name: "Packaged Memory overview" });
|
||||
suite.define(() => {
|
||||
it("loads packaged JS and CSS in the actual Memory route, probes explicitly, and remounts after navigation", async () => {
|
||||
await suite.withPage(
|
||||
{
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
locale: "en-US",
|
||||
reducedMotion: "reduce",
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
async ({ page }) => {
|
||||
const assets: Array<{ url: string; status: number }> = [];
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("response", (response) => {
|
||||
if (response.url().includes("/assets/plugins/memory-core/")) {
|
||||
assets.push({ url: response.url(), status: response.status() });
|
||||
}
|
||||
});
|
||||
const config = {
|
||||
agents: { entries: { main: {} } },
|
||||
plugins: { slots: { memory: "custom-memory" } },
|
||||
};
|
||||
const gateway = await installMockGateway(page, {
|
||||
assistantAgentId: "main",
|
||||
defaultAgentId: "main",
|
||||
communityInvite: false,
|
||||
methodResponses: {
|
||||
"config.get": {
|
||||
config,
|
||||
sourceConfig: config,
|
||||
hash: "overview-config",
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: JSON.stringify(config),
|
||||
},
|
||||
"plugins.list": { plugins: [], diagnostics: [], mutationAllowed: true },
|
||||
"plugins.controlUi.list": { plugins: [], diagnostics: [], revision: "empty" },
|
||||
"doctor.memory.status": {
|
||||
agentId: "main",
|
||||
provider: "local",
|
||||
embedding: { ok: false, checked: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}settings/memory`);
|
||||
await waitForControlUiRoute(page, { routeId: "memory" });
|
||||
const overview = page.locator(".memory-overview");
|
||||
await expect(overview.getByRole("heading", { name: "Memory is awake" })).toBeVisible();
|
||||
await expect(overview).toContainText("custom-memory");
|
||||
await expect(overview.locator(".lob-reading-book")).toBeVisible();
|
||||
for (const title of ["Embeddings", "Read the dream diary", "Configure memory"]) {
|
||||
await expect(overview.locator(".settings-row", { hasText: title })).toHaveCSS(
|
||||
"border-top-width",
|
||||
"1px",
|
||||
);
|
||||
}
|
||||
expect(assets.some((asset) => asset.url.endsWith(".js") && asset.status === 200)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(assets.some((asset) => asset.url.endsWith(".css") && asset.status === 200)).toBe(
|
||||
true,
|
||||
);
|
||||
const ordinary = await gateway.getRequests("doctor.memory.status");
|
||||
expect(ordinary).toHaveLength(1);
|
||||
expect(ordinary[0]?.params).toEqual({ agentId: "main" });
|
||||
const frame = await takeControlUiScreenshotFrame(
|
||||
page,
|
||||
overview,
|
||||
[
|
||||
overview.getByRole("heading", { name: "Memory is awake" }),
|
||||
overview.getByRole("button", { name: "Test", exact: true }),
|
||||
],
|
||||
{ elements: [overview], animations: "disabled" },
|
||||
);
|
||||
await fs.writeFile(path.join(suite.artifactDir, "memory-overview-after.png"), frame.png);
|
||||
await fs.writeFile(
|
||||
path.join(suite.artifactDir, "memory-overview-after-crop.png"),
|
||||
frame.elements[0]!.png,
|
||||
);
|
||||
await gateway.setMethodResponse("doctor.memory.status", {
|
||||
agentId: "main",
|
||||
provider: "local",
|
||||
embedding: { ok: false, checked: true, error: "embedding probe failed" },
|
||||
});
|
||||
await overview.getByRole("button", { name: "Test", exact: true }).click();
|
||||
await gateway.waitForRequest("doctor.memory.status", {
|
||||
match: { agentId: "main", probe: true },
|
||||
});
|
||||
await expect(overview).toContainText("embedding probe failed");
|
||||
await page.locator("#memory-tab-settings").click();
|
||||
await expect(overview).toHaveCount(0);
|
||||
await expect(page.locator('link[href*="/assets/plugins/memory-core/"]')).toHaveCount(0);
|
||||
await page.locator("#memory-tab-overview").click();
|
||||
await expect(overview).toContainText("embedding probe failed");
|
||||
expect(await gateway.getRequests("doctor.memory.status")).toHaveLength(3);
|
||||
await gateway.setOnline(false);
|
||||
await gateway.closeLatest();
|
||||
await expect(overview).toContainText("gateway is offline");
|
||||
expect(await gateway.getRequests("doctor.memory.status")).toHaveLength(3);
|
||||
expect(errors).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
it.each(["none", "memory-core"])(
|
||||
"keeps the packaged overview available for %s without runtime activation",
|
||||
async (slot) => {
|
||||
await suite.withPage(
|
||||
{ viewport: { width: 1440, height: 1000 }, locale: "en-US", reducedMotion: "reduce" },
|
||||
async ({ page }) => {
|
||||
const config = { plugins: { slots: { memory: slot } } };
|
||||
const gateway = await installMockGateway(page, {
|
||||
communityInvite: false,
|
||||
methodResponses: {
|
||||
"config.get": {
|
||||
config,
|
||||
sourceConfig: config,
|
||||
hash: "memory-off",
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: JSON.stringify(config),
|
||||
},
|
||||
"plugins.list": {
|
||||
plugins: [
|
||||
{
|
||||
id: "memory-core",
|
||||
name: "Memory Core",
|
||||
kind: ["memory"],
|
||||
installed: true,
|
||||
enabled: false,
|
||||
state: "disabled",
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
mutationAllowed: true,
|
||||
},
|
||||
"plugins.controlUi.list": { plugins: [], diagnostics: [], revision: "empty" },
|
||||
"doctor.memory.status": {
|
||||
agentId: "main",
|
||||
provider: "local",
|
||||
embedding: { ok: false, checked: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}settings/memory`);
|
||||
await waitForControlUiRoute(page, { routeId: "memory" });
|
||||
await expect(page.getByRole("heading", { name: "Memory is hibernating" })).toBeVisible();
|
||||
if (slot === "memory-core") {
|
||||
await gateway.waitForRequest("doctor.memory.status");
|
||||
}
|
||||
expect(await gateway.getRequests("doctor.memory.status")).toHaveLength(
|
||||
slot === "none" ? 0 : 1,
|
||||
);
|
||||
expect(await gateway.getRequests("plugins.controlUi.report")).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const sourceSuite = createControlUiE2eSuite({
|
||||
name: "Source Memory overview",
|
||||
startServer: () => startControlUiE2eServer(undefined, { source: true }),
|
||||
});
|
||||
sourceSuite.define(() => {
|
||||
it("loads the actual Memory route from a development server without runtime plugin activation", async () => {
|
||||
await sourceSuite.withPage({ locale: "en-US", serviceWorkers: "block" }, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"plugins.controlUi.list": { plugins: [], diagnostics: [], revision: "empty" },
|
||||
"doctor.memory.status": {
|
||||
agentId: "main",
|
||||
provider: "local",
|
||||
embedding: { ok: false, checked: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto(`${sourceSuite.server.baseUrl}settings/memory`);
|
||||
await waitForControlUiRoute(page, { routeId: "memory" });
|
||||
const overview = page.locator(".memory-overview");
|
||||
await expect(overview.getByRole("heading", { name: "Memory is awake" })).toBeVisible();
|
||||
await expect(overview.locator(".settings-row", { hasText: "Embeddings" })).toHaveCSS(
|
||||
"border-top-width",
|
||||
"1px",
|
||||
);
|
||||
await expect(page.locator('link[href*="/assets/plugins/memory-core/"]')).toHaveCount(1);
|
||||
expect(await gateway.getRequests("doctor.memory.status")).toHaveLength(1);
|
||||
expect(await gateway.getRequests("plugins.controlUi.report")).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1587,69 +1587,7 @@ const enSettings = {
|
||||
dreams: "Dreams",
|
||||
settings: "Settings",
|
||||
},
|
||||
overview: {
|
||||
hero: {
|
||||
awake: "Memory is awake",
|
||||
waking: "Waking memory…",
|
||||
hibernating: "Memory is hibernating",
|
||||
needsAttention: "Memory needs attention",
|
||||
noSearchRuntime: "Host memory search is unavailable",
|
||||
noSearchRuntimeDescription:
|
||||
"{engine} does not provide a host memory search runtime. Other memory integrations may run independently.",
|
||||
activeDescription: "{engine} · {mode}",
|
||||
loadingDescription: "Checking this agent's memory engine and dream cycle.",
|
||||
offDescription: "Choose a memory engine in Settings to wake it up.",
|
||||
disabledDescription: "The selected memory engine is disabled. Re-enable it in Settings.",
|
||||
gatewayOffline: "The gateway is offline, so memory status is unavailable.",
|
||||
hybridSearch: "hybrid search",
|
||||
keywordSearch: "keyword search (no embeddings)",
|
||||
openSettings: "Open Settings",
|
||||
retry: "Retry",
|
||||
refresh: "Refresh status",
|
||||
},
|
||||
schedule: {
|
||||
title: "Sleep schedule",
|
||||
lightDescription:
|
||||
"Sorts fresh short-term notes and stages promising candidates without changing long-term memory.",
|
||||
remDescription:
|
||||
"Reflects on themes and recurring ideas across recent activity to strengthen ranking without changing long-term memory.",
|
||||
deepDescription:
|
||||
"Scores staged candidates, promotes the keepers into long-term memory (MEMORY.md), and writes the dream diary.",
|
||||
nextRun: "next {time}",
|
||||
lastRun: "last {time}",
|
||||
notScheduled: "Not scheduled",
|
||||
learnMore: "How dreaming works",
|
||||
openDocs: "Open dreaming guide",
|
||||
},
|
||||
activity: {
|
||||
title: "Activity",
|
||||
promotedToday: "Promoted today",
|
||||
promotedTotal: "Promoted total",
|
||||
shortTermCount: "Pending short-term entries",
|
||||
phaseHitCount: "Phase signals",
|
||||
lightPhaseHitCount: "Light-phase hits",
|
||||
remPhaseHitCount: "REM-phase hits",
|
||||
},
|
||||
health: {
|
||||
title: "Engine health",
|
||||
provider: "Provider",
|
||||
embeddings: "Embeddings",
|
||||
runtime: "Embedding runtime",
|
||||
healthy: "Ready",
|
||||
unavailable: "Unavailable",
|
||||
notChecked: "Not checked",
|
||||
notCheckedDescription: "Embedding readiness has not been checked yet.",
|
||||
checking: "Checking…",
|
||||
test: "Test",
|
||||
testing: "Testing…",
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -278,6 +278,9 @@ it("mounts only selected tool replacements and keeps their context, projection,
|
||||
reload: async () => undefined,
|
||||
refresh: async () => undefined,
|
||||
registrations: () => [],
|
||||
loadSettingsView: async () => {
|
||||
throw new Error("This chat fixture does not load Settings presentations.");
|
||||
},
|
||||
selectReplacement: vi.fn(),
|
||||
selectedReplacement: (surface) =>
|
||||
surface === "tool-result" && selected
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-methods/doctor.ts";
|
||||
import { ShellLayoutBoundary } from "../../app/shell-layout-traits-solid.tsx";
|
||||
import { LobsterSvg } from "../../components/lobster-pet-artwork.tsx";
|
||||
import { lobsterPetSeed } from "../../components/lobster-pet-contract.ts";
|
||||
import { createLobsterPetLook, lobsterLookStyle } from "../../components/lobster-pet-look.ts";
|
||||
import {
|
||||
SettingsNavRow,
|
||||
SettingsRow,
|
||||
SettingsSection,
|
||||
SettingsStatus,
|
||||
SettingsValue,
|
||||
} from "../../components/solid/settings-ui.tsx";
|
||||
import { registerSettingsEnglish } from "../../i18n/locales/en-settings.ts";
|
||||
import { formatRelativeTimestamp } from "../../lib/format.ts";
|
||||
import { t, registerEnglishCatalog } from "../../lib/reactive/i18n.ts";
|
||||
import "../../styles/memory-overview.css";
|
||||
import type { MemoryEngineSelection } from "./memory-schema.ts";
|
||||
import { selectedEngineId } from "./memory-schema.ts";
|
||||
|
||||
registerEnglishCatalog(registerSettingsEnglish);
|
||||
|
||||
export type MemoryOverviewStatus =
|
||||
| { kind: "idle" | "loading" }
|
||||
| { kind: "ready"; payload: DoctorMemoryStatusPayload }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
type MemoryOverviewProps = {
|
||||
agentId: string | null;
|
||||
engineSelection: MemoryEngineSelection;
|
||||
engineDisabled: boolean;
|
||||
status: MemoryOverviewStatus;
|
||||
probingEmbeddings: boolean;
|
||||
onRefresh: () => void;
|
||||
onProbeEmbeddings: () => void;
|
||||
onNavigate: (tab: "memories" | "dreams" | "settings") => void;
|
||||
};
|
||||
|
||||
type DreamingStatus = NonNullable<DoctorMemoryStatusPayload["dreaming"]>;
|
||||
type DreamingPhase = DreamingStatus["phases"][keyof DreamingStatus["phases"]] & {
|
||||
lastRunAtMs?: number;
|
||||
};
|
||||
|
||||
function hasEmbeddingError(payload: DoctorMemoryStatusPayload): boolean {
|
||||
return !payload.embedding.ok && payload.embedding.checked !== false;
|
||||
}
|
||||
|
||||
function searchMode(payload: DoctorMemoryStatusPayload): string {
|
||||
return payload.provider === "none"
|
||||
? t("memoryPage.overview.hero.keywordSearch")
|
||||
: t("memoryPage.overview.hero.hybridSearch");
|
||||
}
|
||||
|
||||
function Hero(props: MemoryOverviewProps) {
|
||||
const engineId = () => selectedEngineId(props.engineSelection);
|
||||
const off = () => props.engineSelection.kind === "off" || props.engineDisabled;
|
||||
const readyPayload = createMemo(() =>
|
||||
props.status.kind === "ready" ? props.status.payload : null,
|
||||
);
|
||||
const noSearchRuntime = () => readyPayload()?.searchRuntimeRegistered === false;
|
||||
const error = () => {
|
||||
const payload = readyPayload();
|
||||
return (
|
||||
props.status.kind === "error" ||
|
||||
(!noSearchRuntime() && payload !== null && hasEmbeddingError(payload))
|
||||
);
|
||||
};
|
||||
const look = createMemo(() => createLobsterPetLook(lobsterPetSeed(props.agentId ?? "memory")));
|
||||
const headline = () =>
|
||||
off()
|
||||
? t("memoryPage.overview.hero.hibernating")
|
||||
: props.status.kind === "loading" || props.status.kind === "idle"
|
||||
? t("memoryPage.overview.hero.waking")
|
||||
: noSearchRuntime()
|
||||
? t("memoryPage.overview.hero.noSearchRuntime")
|
||||
: error()
|
||||
? t("memoryPage.overview.hero.needsAttention")
|
||||
: t("memoryPage.overview.hero.awake");
|
||||
const description = () => {
|
||||
const payload = readyPayload();
|
||||
return off()
|
||||
? t(
|
||||
props.engineDisabled
|
||||
? "memoryPage.overview.hero.disabledDescription"
|
||||
: "memoryPage.overview.hero.offDescription",
|
||||
)
|
||||
: props.status.kind === "error"
|
||||
? props.status.message
|
||||
: payload
|
||||
? noSearchRuntime()
|
||||
? t("memoryPage.overview.hero.noSearchRuntimeDescription", {
|
||||
engine: engineId() ?? t("common.unknown"),
|
||||
})
|
||||
: hasEmbeddingError(payload)
|
||||
? (payload.embedding.error ?? t("memoryPage.overview.health.unavailable"))
|
||||
: t("memoryPage.overview.hero.activeDescription", {
|
||||
engine: engineId() ?? t("common.unknown"),
|
||||
mode: searchMode(payload),
|
||||
})
|
||||
: t("memoryPage.overview.hero.loadingDescription");
|
||||
};
|
||||
const pose = createMemo(() =>
|
||||
off()
|
||||
? { sleeping: true }
|
||||
: error()
|
||||
? { grumpy: true, standalone: true }
|
||||
: readyPayload() && !noSearchRuntime()
|
||||
? { reading: true, standalone: true }
|
||||
: { standalone: true },
|
||||
);
|
||||
return (
|
||||
<section class={["memory-overview__hero", { "memory-overview__hero--sleeping": off() }]}>
|
||||
<div class="memory-overview__lobster" style={lobsterLookStyle(look())}>
|
||||
<LobsterSvg look={look()} {...pose()} />
|
||||
</div>
|
||||
<div class="memory-overview__hero-copy">
|
||||
<h2>{headline()}</h2>
|
||||
<p class={{ "memory-overview__hero-error": error() }}>{description()}</p>
|
||||
<div class="memory-overview__hero-actions">
|
||||
{off() ? (
|
||||
<button class="btn btn--sm" onClick={() => props.onNavigate("settings")}>
|
||||
{t("memoryPage.overview.hero.openSettings")}
|
||||
</button>
|
||||
) : (
|
||||
<button class="btn btn--sm" onClick={props.onRefresh}>
|
||||
{props.status.kind === "error"
|
||||
? t("memoryPage.overview.hero.retry")
|
||||
: t("memoryPage.overview.hero.refresh")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseScheduleDescription(
|
||||
phase: DreamingPhase,
|
||||
timezone: string | undefined,
|
||||
scheduled: boolean,
|
||||
) {
|
||||
const details = [
|
||||
phase.cron || t("common.na"),
|
||||
timezone,
|
||||
scheduled && phase.nextRunAtMs
|
||||
? t("memoryPage.overview.schedule.nextRun", {
|
||||
time: formatRelativeTimestamp(phase.nextRunAtMs),
|
||||
})
|
||||
: null,
|
||||
phase.lastRunAtMs
|
||||
? t("memoryPage.overview.schedule.lastRun", {
|
||||
time: formatRelativeTimestamp(phase.lastRunAtMs),
|
||||
})
|
||||
: null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
return details.join(" · ");
|
||||
}
|
||||
|
||||
function Schedule(props: { dreaming: DreamingStatus }) {
|
||||
const phases = createMemo(
|
||||
() =>
|
||||
[
|
||||
["light", props.dreaming.phases.light],
|
||||
["rem", props.dreaming.phases.rem],
|
||||
["deep", props.dreaming.phases.deep],
|
||||
] as const,
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={t("memoryPage.overview.schedule.title")}>
|
||||
<>
|
||||
<For each={phases()} keyed={(entry) => entry[0]}>
|
||||
{(entry) => (
|
||||
<SettingsRow
|
||||
title={t(`memoryPage.dreaming.phases.${entry()[0]}.title`)}
|
||||
description={
|
||||
<>
|
||||
{t(`memoryPage.overview.schedule.${entry()[0]}Description`)}
|
||||
<br />
|
||||
{phaseScheduleDescription(
|
||||
entry()[1],
|
||||
props.dreaming.timezone,
|
||||
props.dreaming.enabled && entry()[1].enabled && entry()[1].managedCronPresent,
|
||||
)}
|
||||
</>
|
||||
}
|
||||
control={
|
||||
<SettingsStatus
|
||||
kind={
|
||||
props.dreaming.enabled && entry()[1].enabled && entry()[1].managedCronPresent
|
||||
? "ok"
|
||||
: "muted"
|
||||
}
|
||||
label={
|
||||
!props.dreaming.enabled || !entry()[1].enabled
|
||||
? t("common.disabled")
|
||||
: entry()[1].managedCronPresent
|
||||
? t("common.enabled")
|
||||
: t("memoryPage.overview.schedule.notScheduled")
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<SettingsRow
|
||||
title={t("memoryPage.overview.schedule.learnMore")}
|
||||
control={
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
class="memory-page__link"
|
||||
href="https://docs.openclaw.ai/concepts/dreaming"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t("memoryPage.overview.schedule.openDocs")}
|
||||
</a>{" "}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function Activity(props: { dreaming: DreamingStatus }) {
|
||||
const rows = createMemo(
|
||||
() =>
|
||||
[
|
||||
["promotedToday", props.dreaming.promotedToday],
|
||||
["promotedTotal", props.dreaming.promotedTotal],
|
||||
["shortTermCount", props.dreaming.shortTermCount],
|
||||
["phaseHitCount", props.dreaming.phaseSignalCount],
|
||||
["lightPhaseHitCount", props.dreaming.lightPhaseHitCount],
|
||||
["remPhaseHitCount", props.dreaming.remPhaseHitCount],
|
||||
] as const,
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={t("memoryPage.overview.activity.title")}>
|
||||
<For each={rows()} keyed={(entry) => entry[0]}>
|
||||
{(entry) => (
|
||||
<SettingsRow
|
||||
title={t(`memoryPage.overview.activity.${entry()[0]}`)}
|
||||
control={<SettingsValue value={entry()[1]} />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineHealth(props: {
|
||||
payload: DoctorMemoryStatusPayload;
|
||||
overview: MemoryOverviewProps;
|
||||
}) {
|
||||
const notChecked = () => props.payload.embedding.checked === false;
|
||||
const embeddingKind = () =>
|
||||
props.payload.embedding.ok ? "ok" : notChecked() ? "muted" : "danger";
|
||||
const embeddingLabel = () =>
|
||||
props.overview.probingEmbeddings
|
||||
? t("memoryPage.overview.health.checking")
|
||||
: props.payload.embedding.ok
|
||||
? t("memoryPage.overview.health.healthy")
|
||||
: notChecked()
|
||||
? t("memoryPage.overview.health.notChecked")
|
||||
: t("memoryPage.overview.health.unavailable");
|
||||
return (
|
||||
<SettingsSection title={t("memoryPage.overview.health.title")}>
|
||||
<>
|
||||
<SettingsRow
|
||||
title={t("memoryPage.overview.health.provider")}
|
||||
control={
|
||||
<SettingsValue mono={true} value={props.payload.provider ?? t("common.unknown")} />
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
title={t("memoryPage.overview.health.embeddings")}
|
||||
description={
|
||||
props.payload.embedding.ok
|
||||
? undefined
|
||||
: notChecked()
|
||||
? t("memoryPage.overview.health.notCheckedDescription")
|
||||
: props.payload.embedding.error
|
||||
}
|
||||
control={
|
||||
<>
|
||||
<SettingsStatus kind={embeddingKind()} label={embeddingLabel()} />
|
||||
{notChecked() ? (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
disabled={props.overview.probingEmbeddings}
|
||||
onClick={props.overview.onProbeEmbeddings}
|
||||
>
|
||||
{props.overview.probingEmbeddings
|
||||
? t("memoryPage.overview.health.testing")
|
||||
: t("memoryPage.overview.health.test")}
|
||||
</button>{" "}
|
||||
</>
|
||||
) : undefined}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{props.payload.embeddingRuntime ? (
|
||||
<SettingsRow
|
||||
title={t("memoryPage.overview.health.runtime")}
|
||||
description={props.payload.embeddingRuntime.loadError}
|
||||
control={
|
||||
<SettingsValue
|
||||
value={[
|
||||
props.payload.embeddingRuntime.engine,
|
||||
props.payload.embeddingRuntime.backend,
|
||||
props.payload.embeddingRuntime.buildInfo,
|
||||
props.payload.embeddingRuntime.model?.id,
|
||||
props.payload.embeddingRuntime.endpoints
|
||||
? Object.entries(props.payload.embeddingRuntime.endpoints)
|
||||
.map(([name, state]) => `${name}=${state}`)
|
||||
.join(" ")
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCards(props: MemoryOverviewProps) {
|
||||
const payload = createMemo(() => (props.status.kind === "ready" ? props.status.payload : null));
|
||||
return (
|
||||
<Show when={payload()}>
|
||||
{(current) => (
|
||||
<>
|
||||
<Show when={current().dreaming}>
|
||||
{(dreaming) => (
|
||||
<>
|
||||
<Schedule dreaming={dreaming()} />
|
||||
<Activity dreaming={dreaming()} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
{current().searchRuntimeRegistered !== false && (
|
||||
<EngineHealth payload={current()} overview={props} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcuts(props: MemoryOverviewProps) {
|
||||
return (
|
||||
<SettingsSection title={t("memoryPage.overview.shortcuts.title")}>
|
||||
<For
|
||||
each={
|
||||
[
|
||||
["memories", "memoryPage.overview.shortcuts.memories"],
|
||||
["dreams", "memoryPage.overview.shortcuts.diary"],
|
||||
["settings", "memoryPage.overview.shortcuts.settings"],
|
||||
] as const
|
||||
}
|
||||
keyed={(entry) => entry[0]}
|
||||
>
|
||||
{(entry) => (
|
||||
<SettingsNavRow title={t(entry()[1])} onClick={() => props.onNavigate(entry()[0])} />
|
||||
)}
|
||||
</For>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryOverview(props: MemoryOverviewProps) {
|
||||
return (
|
||||
<ShellLayoutBoundary traits={{ settingsPage: true }}>
|
||||
<div class="settings-page memory-overview">
|
||||
<Hero {...props} />{" "}
|
||||
{props.engineSelection.kind !== "off" && !props.engineDisabled ? (
|
||||
<StatusCards {...props} />
|
||||
) : undefined}{" "}
|
||||
<Shortcuts {...props} />
|
||||
</div>
|
||||
</ShellLayoutBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { createSignal } from "solid-js";
|
||||
import { vi } from "vitest";
|
||||
import type {
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsSnapshot,
|
||||
} from "../../../../src/plugin-sdk/control-ui-settings.js";
|
||||
import { createAgentSelectionCapability } from "../../app/agent-selection.ts";
|
||||
import type {
|
||||
ApplicationContext,
|
||||
@@ -101,11 +104,15 @@ export function createMemoryPage(params: {
|
||||
basePath?: string;
|
||||
agents?: Array<{ id: string; name?: string }>;
|
||||
selectedAgentId?: string;
|
||||
memoryStatus?: (agentId: string, probe: boolean) => Promise<unknown>;
|
||||
settingsRequest?: (method: string, payload?: unknown) => Promise<unknown>;
|
||||
processInfo?: (call: number) => Promise<{ processInstanceId?: string }>;
|
||||
scopes?: string[];
|
||||
lookupSchemaPath?: (call: number) => Promise<unknown>;
|
||||
}) {
|
||||
const settingsViews: Array<{
|
||||
context: ControlUiSettingsContext;
|
||||
snapshot: ControlUiSettingsSnapshot;
|
||||
}> = [];
|
||||
let listCalls = 0;
|
||||
let schemaLookups = 0;
|
||||
let systemInfoCalls = 0;
|
||||
@@ -123,21 +130,10 @@ export function createMemoryPage(params: {
|
||||
mutationAllowed: catalog.mutationAllowed ?? params.mutationAllowed ?? true,
|
||||
}));
|
||||
}
|
||||
if (method === "doctor.memory.status") {
|
||||
const input = asOptionalRecord(payload);
|
||||
const agentId = typeof input?.agentId === "string" ? input.agentId : "main";
|
||||
return params.memoryStatus
|
||||
? params.memoryStatus(agentId, input?.probe === true)
|
||||
: Promise.resolve({
|
||||
agentId,
|
||||
provider: "none",
|
||||
embedding: { ok: false, checked: false },
|
||||
});
|
||||
}
|
||||
if (method === "system.info" && params.processInfo) {
|
||||
return params.processInfo(systemInfoCalls++);
|
||||
}
|
||||
return Promise.resolve({});
|
||||
return params.settingsRequest?.(method, payload) ?? Promise.resolve({});
|
||||
});
|
||||
vi.mocked(setPluginEnabled).mockImplementation(
|
||||
(_client, pluginId, enabled) =>
|
||||
@@ -230,6 +226,33 @@ export function createMemoryPage(params: {
|
||||
basePath: params.basePath ?? "",
|
||||
gateway,
|
||||
runtimeConfig,
|
||||
plugins: {
|
||||
reportError: vi.fn(),
|
||||
async loadSettingsView(placement: { section: string; panel: string }, signal: AbortSignal) {
|
||||
return {
|
||||
signal,
|
||||
dispose() {},
|
||||
view: {
|
||||
placement,
|
||||
mount(container: HTMLElement, viewContext: ControlUiSettingsContext) {
|
||||
const view = { context: viewContext, snapshot: viewContext.props };
|
||||
settingsViews.push(view);
|
||||
const marker = document.createElement("div");
|
||||
marker.dataset.settingsView = `${placement.section}/${placement.panel}`;
|
||||
container.append(marker);
|
||||
return {
|
||||
update(snapshot: ControlUiSettingsSnapshot) {
|
||||
view.snapshot = snapshot;
|
||||
},
|
||||
dispose() {
|
||||
marker.remove();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
state: {
|
||||
agentsList: {
|
||||
@@ -237,6 +260,7 @@ export function createMemoryPage(params: {
|
||||
agents: params.agents ?? [{ id: "main" }],
|
||||
},
|
||||
agentsLoading: false,
|
||||
agentsError: null,
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
ensureList: () => Promise.resolve(),
|
||||
@@ -277,6 +301,10 @@ export function createMemoryPage(params: {
|
||||
get: () => input.configObject,
|
||||
set: (value: Record<string, unknown>) => {
|
||||
input.configObject = value;
|
||||
runtimeConfig.state = { ...runtimeConfig.state, configForm: value };
|
||||
for (const notify of runtimeListeners) {
|
||||
notify();
|
||||
}
|
||||
setRevision((n) => n + 1);
|
||||
},
|
||||
},
|
||||
@@ -362,6 +390,11 @@ export function createMemoryPage(params: {
|
||||
runExternalMutation: runtimeConfig.runExternalMutation,
|
||||
lookupSchemaPath: runtimeConfig.lookupSchemaPath,
|
||||
runtimeConfig,
|
||||
settingsViews,
|
||||
context,
|
||||
replaceClient() {
|
||||
publishGateway({ ...gateway.snapshot, client: createTestGatewayClient(request) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-methods/doctor.ts";
|
||||
import { setPluginEnabled, type PluginCatalogItem } from "../../lib/plugins/index.ts";
|
||||
import { flush, waitForSolid } from "../../test-helpers/solid-settle.ts";
|
||||
import {
|
||||
@@ -39,7 +38,7 @@ function visibleTab(element: HTMLElement): "overview" | "memories" | "dreams" |
|
||||
if (panel.querySelector("openclaw-memory-memories")) {
|
||||
return "memories";
|
||||
}
|
||||
return panel.querySelector(".memory-overview") ? "overview" : "settings";
|
||||
return panel.querySelector('[data-settings-view="memory/overview"]') ? "overview" : "settings";
|
||||
}
|
||||
|
||||
function selectTab(element: HTMLElement, tab: string) {
|
||||
@@ -382,63 +381,6 @@ describe("MemorySettingsPage catalog state", () => {
|
||||
});
|
||||
|
||||
describe("MemorySettingsPage tab routing", () => {
|
||||
it("probes embeddings through the guarded overview request and renders the result", async () => {
|
||||
const probe = deferred<DoctorMemoryStatusPayload>();
|
||||
const initial: DoctorMemoryStatusPayload = {
|
||||
agentId: "main",
|
||||
provider: "local",
|
||||
embedding: { ok: false, checked: false },
|
||||
};
|
||||
const { element, request } = createPage({
|
||||
configObject: {},
|
||||
memoryStatus: (_agentId, shouldProbe) =>
|
||||
shouldProbe ? probe.promise : Promise.resolve(initial),
|
||||
});
|
||||
element.routeData = memoryTabRoute("overview");
|
||||
mountMemoryPage(element);
|
||||
try {
|
||||
await waitForSolid(() =>
|
||||
expect(
|
||||
[...element.querySelectorAll<HTMLButtonElement>("button")].some(
|
||||
(button) => button.textContent?.trim() === "Test",
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
const testButton = [...element.querySelectorAll<HTMLButtonElement>("button")].find(
|
||||
(button) => button.textContent?.trim() === "Test",
|
||||
);
|
||||
testButton?.click();
|
||||
|
||||
await waitForSolid(() =>
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.status", {
|
||||
agentId: "main",
|
||||
probe: true,
|
||||
}),
|
||||
);
|
||||
await waitForSolid(() =>
|
||||
expect(
|
||||
[...element.querySelectorAll<HTMLButtonElement>("button")].find(
|
||||
(button) => button.textContent?.trim() === "Testing…",
|
||||
)?.disabled,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
probe.resolve({
|
||||
agentId: "main",
|
||||
provider: "local",
|
||||
embedding: { ok: false, checked: true, error: "embedding probe failed" },
|
||||
});
|
||||
await waitForSolid(() => expect(element.textContent).toContain("embedding probe failed"));
|
||||
expect(
|
||||
[...element.querySelectorAll<HTMLButtonElement>("button")].some(
|
||||
(button) => button.textContent?.trim() === "Test",
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders every canonical tab path and honors browser history restoration", async () => {
|
||||
const navigate = vi.fn();
|
||||
const { element } = createPage({ configObject: {}, catalog: [], navigate });
|
||||
@@ -453,7 +395,7 @@ describe("MemorySettingsPage tab routing", () => {
|
||||
expect(navigate).toHaveBeenCalledWith("memory", { pathname: "/settings/memory" });
|
||||
element.routeData = memoryTabRoute("overview");
|
||||
flush();
|
||||
expect(visibleTab(element)).toBe("overview");
|
||||
await waitForSolid(() => expect(visibleTab(element)).toBe("overview"));
|
||||
|
||||
// The router feeding an older history entry back must restore that tab.
|
||||
element.routeData = memoryTabRoute("settings");
|
||||
@@ -568,66 +510,47 @@ describe("MemorySettingsPage tab routing", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("loads Overview status once per activation, Settings agent change, and reconnect", async () => {
|
||||
const memoryStatus = vi.fn((agentId: string) =>
|
||||
Promise.resolve({ agentId, provider: "none", embedding: { ok: false, checked: false } }),
|
||||
);
|
||||
const { element, request, setPhase, settingsAgentSelection } = createPage({
|
||||
it("mounts the presentation for the Settings agent and retires it on reconnect", async () => {
|
||||
const { element, settingsViews, setPhase, settingsAgentSelection } = createPage({
|
||||
configObject: {},
|
||||
agents: [{ id: "main" }, { id: "research" }],
|
||||
selectedAgentId: "research",
|
||||
memoryStatus,
|
||||
routeData: memoryTabRoute("overview"),
|
||||
});
|
||||
element.routeData = memoryTabRoute("overview");
|
||||
mountMemoryPage(element);
|
||||
try {
|
||||
await waitForSolid(() => expect(memoryStatus).toHaveBeenCalledTimes(1));
|
||||
expect(memoryStatus).toHaveBeenLastCalledWith("research", false);
|
||||
flush();
|
||||
expect(
|
||||
request.mock.calls.filter(([method]) => method === "doctor.memory.status"),
|
||||
).toHaveLength(1);
|
||||
|
||||
await waitForSolid(() => expect(settingsViews.at(-1)?.snapshot.agentId).toBe("research"));
|
||||
const first = settingsViews.at(-1)!;
|
||||
expect(element.querySelectorAll("openclaw-agent-select")).toHaveLength(0);
|
||||
expect(element.textContent).not.toContain("Agent view");
|
||||
settingsAgentSelection.set("main");
|
||||
await waitForSolid(() => expect(memoryStatus).toHaveBeenLastCalledWith("main", false));
|
||||
await waitForSolid(() => expect(settingsViews.at(-1)?.snapshot.agentId).toBe("main"));
|
||||
expect(first.context.signal.aborted).toBe(true);
|
||||
const current = settingsViews.at(-1)!;
|
||||
settingsAgentSelection.setScope(null);
|
||||
flush();
|
||||
expect(memoryStatus).toHaveBeenCalledTimes(2);
|
||||
settingsAgentSelection.set("research");
|
||||
await waitForSolid(() => expect(memoryStatus).toHaveBeenLastCalledWith("research", false));
|
||||
|
||||
expect(settingsViews.at(-1)).toBe(current);
|
||||
setPhase("disconnected");
|
||||
setPhase("connected");
|
||||
await waitForSolid(() => expect(memoryStatus).toHaveBeenCalledTimes(4));
|
||||
expect(current.context.signal.aborted).toBe(true);
|
||||
await waitForSolid(() => expect(settingsViews.at(-1)?.snapshot.connected).toBe(true));
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("reloads status when one pinned engine replaces another", async () => {
|
||||
const memoryStatus = vi.fn((agentId: string) =>
|
||||
Promise.resolve({ agentId, provider: "none", embedding: { ok: false, checked: false } }),
|
||||
);
|
||||
const { element } = createPage({
|
||||
it("passes a replaced pinned engine to a new presentation", async () => {
|
||||
const { element, settingsViews } = createPage({
|
||||
configObject: { plugins: { slots: { memory: "engine-a" } } },
|
||||
catalog: [engine("engine-a", true), engine("engine-b", true)],
|
||||
memoryStatus,
|
||||
routeData: memoryTabRoute("overview"),
|
||||
});
|
||||
element.routeData = memoryTabRoute("overview");
|
||||
mountMemoryPage(element);
|
||||
try {
|
||||
await waitForSolid(() => expect(memoryStatus).toHaveBeenCalledTimes(1));
|
||||
|
||||
await waitForSolid(() => expect(settingsViews.at(-1)?.snapshot.engine.id).toBe("engine-a"));
|
||||
const first = settingsViews.at(-1)!;
|
||||
element.configObject = { plugins: { slots: { memory: "engine-b" } } };
|
||||
await waitForSolid(() => expect(memoryStatus).toHaveBeenCalledTimes(2));
|
||||
await waitForSolid(() => {
|
||||
expect(element.querySelector(".memory-overview__hero h2")?.textContent).toBe(
|
||||
"Memory is awake",
|
||||
);
|
||||
expect(element.textContent).toContain("engine-b");
|
||||
});
|
||||
await waitForSolid(() => expect(settingsViews.at(-1)?.snapshot.engine.id).toBe("engine-b"));
|
||||
expect(first.context.signal.aborted).toBe(true);
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
@@ -636,7 +559,7 @@ describe("MemorySettingsPage tab routing", () => {
|
||||
it.each([["main"], ["research", "main"]])(
|
||||
"keeps newer sidebar intent when a pending Memory link mounts (%j)",
|
||||
async (...choices: string[]) => {
|
||||
const { element, settingsAgentSelection, request } = createPage({
|
||||
const { element, settingsAgentSelection, settingsViews } = createPage({
|
||||
configObject: {},
|
||||
agents: [{ id: "main" }, { id: "research" }],
|
||||
routeData: memoryRoute("/settings/memory?agent=research"),
|
||||
@@ -652,11 +575,9 @@ describe("MemorySettingsPage tab routing", () => {
|
||||
}
|
||||
mountMemoryPage(element);
|
||||
try {
|
||||
await waitForSolid(() =>
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.status", { agentId: "main" }),
|
||||
);
|
||||
await waitForSolid(() => expect(settingsViews.at(-1)?.snapshot.agentId).toBe("main"));
|
||||
expect(settingsAgentSelection.state.selectedId).toBe("main");
|
||||
expect(request).not.toHaveBeenCalledWith("doctor.memory.status", { agentId: "research" });
|
||||
expect(settingsViews.some((view) => view.snapshot.agentId === "research")).toBe(false);
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
@@ -686,61 +607,57 @@ describe("MemorySettingsPage tab routing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes the current Overview after a same-connection plugin publication", async () => {
|
||||
const first = deferred<DoctorMemoryStatusPayload>();
|
||||
const second = deferred<DoctorMemoryStatusPayload>();
|
||||
const memoryStatus = vi.fn().mockReturnValueOnce(first.promise).mockReturnValue(second.promise);
|
||||
const { element, publishPluginGeneration } = createPage({ configObject: {}, memoryStatus });
|
||||
element.routeData = memoryTabRoute("overview");
|
||||
mountMemoryPage(element);
|
||||
try {
|
||||
await waitForSolid(() => expect(memoryStatus).toHaveBeenCalledOnce());
|
||||
publishPluginGeneration(1);
|
||||
await waitForSolid(() => expect(memoryStatus).toHaveBeenCalledTimes(2));
|
||||
second.resolve({
|
||||
agentId: "main",
|
||||
provider: "local",
|
||||
embedding: { ok: false, checked: true, error: "current embedding status" },
|
||||
it.each(["plugin publication", "boot", "client"])(
|
||||
"retires callbacks and pending RPCs synchronously after %s changes",
|
||||
async (change) => {
|
||||
const pending = deferred<unknown>();
|
||||
const fixture = createPage({
|
||||
configObject: {},
|
||||
routeData: memoryTabRoute("overview"),
|
||||
settingsRequest: () => pending.promise,
|
||||
});
|
||||
await waitForSolid(() => expect(element.textContent).toContain("current embedding status"));
|
||||
first.resolve({
|
||||
agentId: "main",
|
||||
provider: "local",
|
||||
embedding: { ok: false, checked: true, error: "obsolete embedding status" },
|
||||
});
|
||||
await first.promise;
|
||||
flush();
|
||||
expect(element.textContent).not.toContain("obsolete embedding status");
|
||||
publishPluginGeneration(1);
|
||||
flush();
|
||||
expect(memoryStatus).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
first.resolve({
|
||||
agentId: "main",
|
||||
provider: "none",
|
||||
embedding: { ok: false, checked: false },
|
||||
});
|
||||
second.resolve({
|
||||
agentId: "main",
|
||||
provider: "none",
|
||||
embedding: { ok: false, checked: false },
|
||||
});
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
const { element, settingsViews, request } = fixture;
|
||||
mountMemoryPage(element);
|
||||
try {
|
||||
await waitForSolid(() => expect(settingsViews.length).toBeGreaterThan(0));
|
||||
const old = settingsViews.at(-1)!.context;
|
||||
const result = old.request("settings.test");
|
||||
if (change === "plugin publication") {
|
||||
fixture.publishPluginGeneration(1);
|
||||
} else if (change === "boot") {
|
||||
fixture.setBootId("memory-boot-b");
|
||||
} else {
|
||||
fixture.replaceClient();
|
||||
}
|
||||
// No Solid flush: live transport authority must retire immediately.
|
||||
expect(old.signal.aborted).toBe(true);
|
||||
expect(() => old.navigate("settings")).toThrow();
|
||||
const dispatches = request.mock.calls.length;
|
||||
await expect(old.request("settings.test")).rejects.toThrow();
|
||||
expect(request).toHaveBeenCalledTimes(dispatches);
|
||||
pending.resolve("obsolete");
|
||||
await expect(result).rejects.toThrow();
|
||||
await waitForSolid(() => expect(settingsViews.at(-1)!.context).not.toBe(old));
|
||||
if (change === "plugin publication") {
|
||||
const current = settingsViews.at(-1)!.context;
|
||||
fixture.publishPluginGeneration(1);
|
||||
flush();
|
||||
expect(settingsViews.at(-1)!.context).toBe(current);
|
||||
}
|
||||
} finally {
|
||||
pending.resolve("done");
|
||||
element.remove();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("shows the offline state when Overview is activated after disconnecting elsewhere", async () => {
|
||||
const { element, setPhase } = createPage({ configObject: {} });
|
||||
it("passes offline state when Overview is activated after disconnecting elsewhere", async () => {
|
||||
const { element, settingsViews, setPhase } = createPage({ configObject: {} });
|
||||
mountMemoryPage(element);
|
||||
try {
|
||||
flush();
|
||||
setPhase("disconnected");
|
||||
element.routeData = memoryTabRoute("overview");
|
||||
await waitForSolid(() =>
|
||||
expect(element.textContent).toContain(
|
||||
"The gateway is offline, so memory status is unavailable.",
|
||||
),
|
||||
);
|
||||
await waitForSolid(() => expect(settingsViews.at(-1)?.snapshot.connected).toBe(false));
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
Show,
|
||||
untrack,
|
||||
} from "solid-js";
|
||||
import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-methods/doctor.ts";
|
||||
import type { ControlUiSettingsSnapshot } from "../../../../src/plugin-sdk/control-ui-settings.js";
|
||||
import { pathForMemoryTab } from "../../app-route-paths.ts";
|
||||
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import { readGatewayOperatorAccess } from "../../app/operator-access.ts";
|
||||
import { ShellLayoutBoundary } from "../../app/shell-layout-traits-solid.tsx";
|
||||
import { i18n } from "../../i18n/lib/translate.ts";
|
||||
import { registerSettingsEnglish } from "../../i18n/locales/en-settings.ts";
|
||||
import { currentConfigObject } from "../../lib/config/config-state-model.ts";
|
||||
import { formatUiError } from "../../lib/format-error.ts";
|
||||
@@ -27,8 +29,9 @@ import {
|
||||
import { projectAgentSelection, projectGateway } from "../../lib/reactive/application.ts";
|
||||
import { useApplication } from "../../lib/reactive/context.ts";
|
||||
import { projectAgents, projectRuntimeConfig } from "../../lib/reactive/domain-capabilities.ts";
|
||||
import { t, registerEnglishCatalog } from "../../lib/reactive/i18n.ts";
|
||||
import { t, getLocale, registerEnglishCatalog } from "../../lib/reactive/i18n.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { ControlUiSettingsPresentation } from "../../plugins/control-ui-settings-view.solid.tsx";
|
||||
import {
|
||||
resolveConfiguredDreaming,
|
||||
resolveDreamingConfigPathSupport,
|
||||
@@ -39,7 +42,6 @@ import { createCuratedSettingsEditor } from "./curated-settings-editor.ts";
|
||||
import { curatedSettingsPresentation } from "./curated-settings-presentation.ts";
|
||||
import { CuratedSettingsPanel } from "./curated-settings.tsx";
|
||||
import { MemoryMemories } from "./memory-memories.tsx";
|
||||
import { MemoryOverview, type MemoryOverviewStatus } from "./memory-overview.tsx";
|
||||
import {
|
||||
canonicalMemoryRouteLocation,
|
||||
memoryTabForRoute,
|
||||
@@ -117,13 +119,16 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
|
||||
const [addonRefreshWarnings, setAddonRefreshWarnings] = createSignal(new Map<string, string>());
|
||||
|
||||
let previousSelectedAgentId: string | null = null;
|
||||
|
||||
const [overviewStatus, setOverviewStatus] = createSignal<MemoryOverviewStatus>({
|
||||
kind: "idle",
|
||||
});
|
||||
|
||||
const [probingEmbeddings, setProbingEmbeddings] = createSignal(false);
|
||||
let settingsEpoch = 0;
|
||||
const settingsListeners = new Set<() => void>();
|
||||
const [settingsRevision, setSettingsRevision] = createSignal(0, { ownedWrite: true });
|
||||
function retireOverviewReads() {
|
||||
settingsEpoch += 1;
|
||||
setSettingsRevision(settingsEpoch);
|
||||
for (const notify of settingsListeners) {
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
const [support, setSupport] = createSignal<DreamingConfigPathSupport>("unknown");
|
||||
|
||||
@@ -133,11 +138,6 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
|
||||
let pageCatalogRequest = 0;
|
||||
|
||||
let pageOverviewRequest: {
|
||||
connection: CatalogConnection;
|
||||
agentId: string;
|
||||
} | null = null;
|
||||
|
||||
let pageSupportPluginId: string | null = null;
|
||||
|
||||
let pageSupportProbe: {
|
||||
@@ -213,18 +213,11 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
setEngineOutcome(null);
|
||||
setAddonBusy(new Set<string>());
|
||||
setAddonRefreshWarnings(new Map());
|
||||
pageOverviewRequest = null;
|
||||
setProbingEmbeddings(false);
|
||||
retireOverviewReads();
|
||||
if (!client || !connected) {
|
||||
setCatalog({
|
||||
kind: "unavailable",
|
||||
});
|
||||
if (activeTab() === "overview") {
|
||||
setOverviewStatus({
|
||||
kind: "error",
|
||||
message: t("memoryPage.overview.hero.gatewayOffline"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCatalog({
|
||||
@@ -239,13 +232,11 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
|
||||
function refreshPluginReads(client: GatewayClient, connection: CatalogConnection) {
|
||||
// Publication supersedes reads, not the connection or a mutation waiting on its receipt.
|
||||
pageOverviewRequest = null;
|
||||
setProbingEmbeddings(false);
|
||||
retireOverviewReads();
|
||||
pageSupportPluginId = null;
|
||||
pageSupportProbe = null;
|
||||
syncSupport(application.runtimeConfig);
|
||||
void loadCatalog(client, connection);
|
||||
void loadOverviewStatus();
|
||||
}
|
||||
|
||||
async function loadCatalog(client: GatewayClient, connection: CatalogConnection) {
|
||||
@@ -275,100 +266,6 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
setCatalog(nextCatalog);
|
||||
}
|
||||
|
||||
function selectAgent(agentId: string | null) {
|
||||
if (previousSelectedAgentId === agentId) {
|
||||
return;
|
||||
}
|
||||
previousSelectedAgentId = agentId;
|
||||
pageOverviewRequest = null;
|
||||
setOverviewStatus({
|
||||
kind: "idle",
|
||||
});
|
||||
setProbingEmbeddings(false);
|
||||
void loadOverviewStatus();
|
||||
}
|
||||
|
||||
async function loadOverviewStatus(
|
||||
options: {
|
||||
force?: boolean;
|
||||
probeEmbeddings?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (activeTab() !== "overview") {
|
||||
return;
|
||||
}
|
||||
if (resolveMemoryEngineSelection(props.configObject).kind === "off") {
|
||||
pageOverviewRequest = null;
|
||||
setOverviewStatus({
|
||||
kind: "idle",
|
||||
});
|
||||
setProbingEmbeddings(false);
|
||||
return;
|
||||
}
|
||||
const connection = pageConnection;
|
||||
const client = connection?.connected ? connection.client : null;
|
||||
const agentId = application.settingsAgentSelection.state.selectedId;
|
||||
if (!connection || !client) {
|
||||
setOverviewStatus({
|
||||
kind: "error",
|
||||
message: t("memoryPage.overview.hero.gatewayOffline"),
|
||||
});
|
||||
setProbingEmbeddings(false);
|
||||
return;
|
||||
}
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!options.force &&
|
||||
pageOverviewRequest?.connection === connection &&
|
||||
pageOverviewRequest.agentId === agentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const probeEmbeddings = options.probeEmbeddings === true;
|
||||
const request = {
|
||||
connection,
|
||||
agentId,
|
||||
};
|
||||
pageOverviewRequest = request;
|
||||
setProbingEmbeddings(probeEmbeddings);
|
||||
if (!probeEmbeddings) {
|
||||
setOverviewStatus({
|
||||
kind: "loading",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const payload = await client.request<DoctorMemoryStatusPayload>("doctor.memory.status", {
|
||||
agentId,
|
||||
...(probeEmbeddings
|
||||
? {
|
||||
probe: true,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (!active || pageOverviewRequest !== request) {
|
||||
return;
|
||||
}
|
||||
setOverviewStatus({
|
||||
kind: "ready",
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!active || pageOverviewRequest !== request) {
|
||||
return;
|
||||
}
|
||||
setOverviewStatus({
|
||||
kind: "error",
|
||||
message: formatUiError(error),
|
||||
});
|
||||
} finally {
|
||||
if (pageOverviewRequest === request) {
|
||||
setProbingEmbeddings(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function engineState(selection: MemoryEngineSelection): MemoryPluginState {
|
||||
const engineId = selectedEngineId(selection);
|
||||
return engineId === null
|
||||
@@ -603,37 +500,21 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
pathname: pathForMemoryTab(tab, application.basePath),
|
||||
});
|
||||
}
|
||||
let previousTab: MemoryTab | undefined;
|
||||
createEffect(
|
||||
() => props.routeData,
|
||||
() => {
|
||||
const nextTab = activeTab();
|
||||
const changed = previousTab !== undefined && previousTab !== nextTab;
|
||||
previousTab = nextTab;
|
||||
if (changed) {
|
||||
pageOverviewRequest = null;
|
||||
setProbingEmbeddings(false);
|
||||
}
|
||||
syncRouteAgent();
|
||||
syncCanonicalLocation();
|
||||
if (changed) {
|
||||
void loadOverviewStatus();
|
||||
}
|
||||
},
|
||||
);
|
||||
// Owner notifications are synchronous: a disconnect/reconnect pair must retire
|
||||
// pending reads even when Solid batches both publications into one render.
|
||||
onSettled(() => {
|
||||
const unsubscribe = [
|
||||
agentSelection.subscribe(() =>
|
||||
untrack(() => selectAgent(agentSelection.read().state.selectedId)),
|
||||
),
|
||||
gateway.subscribe(() => untrack(() => syncGateway(gateway.read().snapshot))),
|
||||
runtime.subscribe(() => untrack(() => syncSupport(application.runtimeConfig))),
|
||||
agents.subscribe(() => untrack(() => void loadOverviewStatus())),
|
||||
];
|
||||
syncRouteAgent();
|
||||
selectAgent(agentSelection.read().state.selectedId);
|
||||
syncGateway(gateway.read().snapshot);
|
||||
return () => {
|
||||
for (const stop of unsubscribe) {
|
||||
@@ -641,24 +522,9 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
}
|
||||
};
|
||||
});
|
||||
let previousEngine: string | null | undefined;
|
||||
createEffect(
|
||||
() => selectedEngineId(resolveMemoryEngineSelection(props.configObject)),
|
||||
(engine) => {
|
||||
const changed = previousEngine !== undefined && engine !== previousEngine;
|
||||
previousEngine = engine;
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
pageOverviewRequest = null;
|
||||
setProbingEmbeddings(false);
|
||||
void loadOverviewStatus();
|
||||
},
|
||||
);
|
||||
onCleanup(() => {
|
||||
active = false;
|
||||
pageConnection = null;
|
||||
pageOverviewRequest = null;
|
||||
pageSupportProbe = null;
|
||||
addonNoticeOperations.clear();
|
||||
});
|
||||
@@ -666,7 +532,57 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
const engineSelection = () => resolveMemoryEngineSelection(props.configObject);
|
||||
const currentEngineState = () => engineState(engineSelection());
|
||||
const agentId = () => agentSelection.read().state.selectedId;
|
||||
const agentError = () => (agentId() ? null : agents.read().agentsError);
|
||||
function readOverviewSettingsSnapshot(): ControlUiSettingsSnapshot {
|
||||
// Track the projections, but read authority directly from each live owner.
|
||||
agentSelection.read();
|
||||
gateway.read();
|
||||
runtime.read();
|
||||
agents.read();
|
||||
settingsRevision();
|
||||
const selection = resolveMemoryEngineSelection(
|
||||
currentConfigObject(application.runtimeConfig.state) ?? {},
|
||||
);
|
||||
const id = selectedEngineId(selection);
|
||||
const entry = findMemoryPlugin(catalog(), id);
|
||||
return {
|
||||
agentId: application.settingsAgentSelection.state.selectedId,
|
||||
agentsLoading: application.agents.state.agentsLoading,
|
||||
agentsError: application.agents.state.agentsError,
|
||||
engine: {
|
||||
kind: selection.kind,
|
||||
id,
|
||||
availability:
|
||||
catalog().kind === "ready" && id && !entry?.installed
|
||||
? "missing"
|
||||
: engineState(selection),
|
||||
},
|
||||
connected: application.gateway.snapshot.phase === "connected",
|
||||
connectionEpoch: settingsEpoch,
|
||||
routeIntent: JSON.stringify([
|
||||
props.routeData?.pathname,
|
||||
props.routeData?.search,
|
||||
props.routeData?.hash,
|
||||
]),
|
||||
locale: getLocale(),
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeOverviewSettings(listener: () => void) {
|
||||
settingsListeners.add(listener);
|
||||
const stops = [
|
||||
agentSelection.subscribe(listener),
|
||||
gateway.subscribe(listener),
|
||||
runtime.subscribe(listener),
|
||||
agents.subscribe(listener),
|
||||
i18n.subscribe(listener),
|
||||
];
|
||||
return () => {
|
||||
settingsListeners.delete(listener);
|
||||
for (const stop of stops) {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<openclaw-memory-settings>
|
||||
@@ -695,22 +611,18 @@ export function MemorySettingsPage(props: MemorySettingsPageProps) {
|
||||
memoryImportHref={props.memoryImportHref}
|
||||
canImportMemory={readGatewayOperatorAccess(gateway.read().snapshot).canAdmin}
|
||||
overview={
|
||||
<MemoryOverview
|
||||
agentId={agentId()}
|
||||
engineSelection={engineSelection()}
|
||||
engineDisabled={currentEngineState() === "disabled"}
|
||||
status={agentError() ? { kind: "error", message: agentError()! } : overviewStatus()}
|
||||
probingEmbeddings={probingEmbeddings()}
|
||||
onRefresh={() =>
|
||||
agentId()
|
||||
? void loadOverviewStatus({ force: true })
|
||||
: void application.agents.ensureList()
|
||||
}
|
||||
onProbeEmbeddings={() =>
|
||||
void loadOverviewStatus({ force: true, probeEmbeddings: true })
|
||||
}
|
||||
onNavigate={navigateTab}
|
||||
/>
|
||||
<ShellLayoutBoundary traits={{ settingsPage: true }}>
|
||||
<ControlUiSettingsPresentation
|
||||
application={application}
|
||||
placement={{ section: "memory", panel: "overview" }}
|
||||
readSnapshot={readOverviewSettingsSnapshot}
|
||||
subscribe={subscribeOverviewSettings}
|
||||
navigate={navigateTab}
|
||||
refreshAgents={() => {
|
||||
void application.agents.ensureList();
|
||||
}}
|
||||
/>
|
||||
</ShellLayoutBoundary>
|
||||
}
|
||||
memories={
|
||||
<MemoryMemories
|
||||
|
||||
@@ -33,6 +33,14 @@ export type ControlUiContributions = {
|
||||
// Application consumers use the registry contract; construction and activation
|
||||
// ownership stay in the runtime without a dependency back through app context.
|
||||
export type ControlUiPluginCapability = {
|
||||
loadSettingsView: (
|
||||
placement: import("../../../src/plugin-sdk/control-ui-settings.js").ControlUiSettingsPlacement,
|
||||
signal: AbortSignal,
|
||||
) => Promise<{
|
||||
view: import("../../../src/plugin-sdk/control-ui-settings.js").ControlUiSettingsView;
|
||||
signal: AbortSignal;
|
||||
dispose: () => void;
|
||||
}>;
|
||||
readonly errors: readonly PluginControlUiDiagnostic[];
|
||||
readonly hasPlugins: boolean;
|
||||
readonly registryStatus: "pending" | "complete" | "failed";
|
||||
|
||||
@@ -3,7 +3,13 @@ import type { PluginControlUiModule } from "../../../packages/gateway-protocol/s
|
||||
export type ControlUiPluginDistribution = Pick<
|
||||
PluginControlUiModule,
|
||||
"pluginId" | "revision" | "bundledDistribution"
|
||||
> & { entry: string; styles: string[]; imports: string[] };
|
||||
> & {
|
||||
entry: string;
|
||||
styles: string[];
|
||||
imports: string[];
|
||||
activation?: "settings-only";
|
||||
settingsViews?: readonly import("../../../src/plugin-sdk/control-ui-settings.js").ControlUiSettingsPlacement[];
|
||||
};
|
||||
|
||||
// The production Vite adapter replaces this module with its emitted asset inventory.
|
||||
// A standalone UI without packaged plugin artifacts keeps the Gateway delivery route.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ControlUiDisposer, ControlUiPlugin } from "../../../src/plugin-sdk/control-ui.js";
|
||||
import type {
|
||||
ControlUiDisposer,
|
||||
ControlUiPlugin,
|
||||
ControlUiSettingsView,
|
||||
} from "../../../src/plugin-sdk/control-ui.js";
|
||||
import type { ApplicationContext } from "../app/context.ts";
|
||||
import { controlUiPluginAssetUrls } from "./control-ui-assets.ts";
|
||||
import { createControlUiPluginHost } from "./control-ui-host.ts";
|
||||
@@ -22,34 +26,13 @@ export async function initializeControlUiPlugin(
|
||||
const complete: ControlUiPluginOwner = Object.assign(owner, {
|
||||
host: createControlUiPluginHost(getContext, runtime, owner),
|
||||
});
|
||||
const stylesLoaded = urls.styles.map(async (url) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.media = "not all";
|
||||
link.href = url;
|
||||
const loaded = new Promise<void>((resolve, reject) => {
|
||||
link.addEventListener("load", () => resolve(), { once: true, signal: abort.signal });
|
||||
link.addEventListener(
|
||||
"error",
|
||||
() => reject(new Error(`Could not load plugin stylesheet: ${descriptor.pluginId}`)),
|
||||
{ once: true, signal: abort.signal },
|
||||
);
|
||||
abort.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("Plugin UI activation ended.")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
document.head.append(link);
|
||||
complete.disposers.add(() => link.remove());
|
||||
styles.push(link);
|
||||
return loaded;
|
||||
});
|
||||
// Fetch code alongside CSS; publish only after this revision's styles are ready.
|
||||
const [module]: [{ default?: ControlUiPlugin }, void[]] = await Promise.all([
|
||||
import(/* @vite-ignore */ urls.entry),
|
||||
Promise.all(stylesLoaded),
|
||||
]);
|
||||
const module = await loadControlUiPluginAssets(
|
||||
descriptor.pluginId,
|
||||
urls,
|
||||
abort.signal,
|
||||
(stop) => complete.disposers.add(stop),
|
||||
styles,
|
||||
);
|
||||
if (!runtime.isCurrent(owner)) {
|
||||
dispose();
|
||||
return undefined;
|
||||
@@ -68,3 +51,48 @@ export async function initializeControlUiPlugin(
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
export type ControlUiPluginModule = {
|
||||
default?: ControlUiPlugin;
|
||||
pluginId?: string;
|
||||
settingsViews?: readonly ControlUiSettingsView[];
|
||||
};
|
||||
|
||||
/** Both activation and packaged Settings presentations share one asset loader. */
|
||||
export async function loadControlUiPluginAssets(
|
||||
pluginId: string,
|
||||
urls: { entry: string; styles: readonly string[] },
|
||||
signal: AbortSignal,
|
||||
retain: (dispose: ControlUiDisposer) => void,
|
||||
styles?: HTMLLinkElement[],
|
||||
): Promise<ControlUiPluginModule> {
|
||||
signal.throwIfAborted();
|
||||
const stylesLoaded = urls.styles.map(async (url) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.media = "not all";
|
||||
link.href = url;
|
||||
const loaded = new Promise<void>((resolve, reject) => {
|
||||
link.addEventListener("load", () => resolve(), { once: true, signal });
|
||||
link.addEventListener(
|
||||
"error",
|
||||
() => reject(new Error(`Could not load plugin stylesheet: ${pluginId}`)),
|
||||
{ once: true, signal },
|
||||
);
|
||||
signal.addEventListener("abort", () => reject(new Error("Plugin UI activation ended.")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
document.head.append(link);
|
||||
retain(() => link.remove());
|
||||
styles?.push(link);
|
||||
return loaded;
|
||||
});
|
||||
// Fetch code alongside CSS; publish only after this revision's styles are ready.
|
||||
const [module]: [ControlUiPluginModule, void[]] = await Promise.all([
|
||||
import(/* @vite-ignore */ urls.entry),
|
||||
Promise.all(stylesLoaded),
|
||||
]);
|
||||
signal.throwIfAborted();
|
||||
return module;
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import type {
|
||||
PluginsControlUiCatalog,
|
||||
} from "../../../packages/gateway-protocol/src/schema/plugins.js";
|
||||
import { CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS } from "../../../src/gateway/control-ui-plugin-frame-contract.js";
|
||||
import type {
|
||||
ControlUiSettingsPlacement,
|
||||
ControlUiSettingsView,
|
||||
} from "../../../src/plugin-sdk/control-ui-settings.js";
|
||||
import type {
|
||||
ControlUiDisposer,
|
||||
ControlUiHost,
|
||||
@@ -28,6 +32,7 @@ import type {
|
||||
ControlUiPluginCapability,
|
||||
ControlUiRegistration,
|
||||
} from "./control-ui-capability.ts";
|
||||
import { controlUiPluginDistributions } from "./control-ui-distribution.ts";
|
||||
|
||||
export type ControlUiPluginOwner = {
|
||||
descriptor: PluginControlUiModule;
|
||||
@@ -74,6 +79,7 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
|
||||
private connectionId: string | null = null;
|
||||
private refreshGeneration = 0;
|
||||
private disposed = false;
|
||||
private readonly settingsLifetimes = new Set<AbortController>();
|
||||
private firstConnection = true;
|
||||
private diagnostics: PluginControlUiDiagnostic[] = [];
|
||||
private grantTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -100,6 +106,88 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
|
||||
return [...this.diagnostics, ...warnings];
|
||||
}
|
||||
|
||||
async loadSettingsView(placement: ControlUiSettingsPlacement, signal: AbortSignal) {
|
||||
if (this.disposed) {
|
||||
throw new Error("The plugin UI runtime has ended.");
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const matches = controlUiPluginDistributions.filter((entry) =>
|
||||
entry.settingsViews?.some(
|
||||
(item) => item.section === placement.section && item.panel === placement.panel,
|
||||
),
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error("Settings presentation requires one packaged owner.");
|
||||
}
|
||||
const distribution = matches[0]!;
|
||||
const abort = new AbortController();
|
||||
const lifetime = AbortSignal.any([signal, abort.signal]);
|
||||
const disposers = new Set<() => void>();
|
||||
let retired = false;
|
||||
const dispose = () => {
|
||||
if (retired) {
|
||||
return;
|
||||
}
|
||||
retired = true;
|
||||
abort.abort();
|
||||
for (const stop of disposers) {
|
||||
stop();
|
||||
}
|
||||
disposers.clear();
|
||||
this.settingsLifetimes.delete(abort);
|
||||
};
|
||||
this.settingsLifetimes.add(abort);
|
||||
lifetime.addEventListener("abort", dispose, { once: true });
|
||||
const styles: HTMLLinkElement[] = [];
|
||||
try {
|
||||
const { loadControlUiPluginAssets } = await import("./control-ui-loader.ts");
|
||||
const module = await raceWithTimeout(
|
||||
loadControlUiPluginAssets(
|
||||
distribution.pluginId,
|
||||
distribution,
|
||||
lifetime,
|
||||
(stop) => disposers.add(stop),
|
||||
styles,
|
||||
),
|
||||
ACTIVATION_TIMEOUT_MS,
|
||||
() => {
|
||||
throw new Error("Settings presentation timed out.");
|
||||
},
|
||||
);
|
||||
lifetime.throwIfAborted();
|
||||
if (
|
||||
module.pluginId !== distribution.pluginId ||
|
||||
!Array.isArray(module.settingsViews) ||
|
||||
module.settingsViews.length !== distribution.settingsViews?.length
|
||||
) {
|
||||
throw new Error("Settings presentation does not match its packaged owner and placements.");
|
||||
}
|
||||
const declared = module.settingsViews.every(
|
||||
(view) =>
|
||||
typeof view.mount === "function" &&
|
||||
distribution.settingsViews?.some(
|
||||
(item) =>
|
||||
item.section === view.placement.section && item.panel === view.placement.panel,
|
||||
),
|
||||
);
|
||||
const views: readonly ControlUiSettingsView[] = module.settingsViews.filter(
|
||||
(view) =>
|
||||
view.placement.section === placement.section && view.placement.panel === placement.panel,
|
||||
);
|
||||
const view = views[0];
|
||||
if (!declared || views.length !== 1 || !view) {
|
||||
throw new Error("Invalid or duplicate Settings presentation.");
|
||||
}
|
||||
for (const style of styles) {
|
||||
style.media = "all";
|
||||
}
|
||||
return { view, signal: lifetime, dispose };
|
||||
} catch (error) {
|
||||
dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
get hasPlugins(): boolean {
|
||||
return this.owners.size > 0 || this.loadingOwners.size > 0;
|
||||
}
|
||||
@@ -608,6 +696,9 @@ export class ControlUiPluginRuntime implements ControlUiPluginCapability {
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
for (const owner of this.settingsLifetimes) {
|
||||
owner.abort();
|
||||
}
|
||||
this.preloads.dispose();
|
||||
this.retireOwners();
|
||||
for (const stop of this.stops) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, type JSX } from "@solidjs/web";
|
||||
import { createMemo, createSignal } from "solid-js";
|
||||
import type { ControlUiComponentHandle } from "../../../src/plugin-sdk/control-ui-components.js";
|
||||
import type { ControlUiSettingsComponents } from "../../../src/plugin-sdk/control-ui-settings.js";
|
||||
import { LobsterSvg } from "../components/lobster-pet-artwork.tsx";
|
||||
import { lobsterPetSeed } from "../components/lobster-pet-contract.ts";
|
||||
import { createLobsterPetLook, lobsterLookStyle } from "../components/lobster-pet-look.ts";
|
||||
import {
|
||||
SettingsNavRow,
|
||||
SettingsRow,
|
||||
SettingsSection,
|
||||
SettingsStatus,
|
||||
SettingsValue,
|
||||
} from "../components/solid/settings-ui.tsx";
|
||||
|
||||
export function createControlUiSettingsComponents(
|
||||
signal: AbortSignal,
|
||||
): ControlUiSettingsComponents {
|
||||
function mount<P extends object>(
|
||||
container: HTMLElement,
|
||||
initial: P,
|
||||
view: (props: () => P) => JSX.Element,
|
||||
row = false,
|
||||
): ControlUiComponentHandle<P> {
|
||||
signal.throwIfAborted();
|
||||
if (row) {
|
||||
container.classList.add("settings-row-mount");
|
||||
}
|
||||
// The bridge applies imperative plugin updates to the host-owned visual root.
|
||||
const [state, setState] = createSignal({ value: initial }, { ownedWrite: true });
|
||||
const stop = render(() => view(() => state().value), container);
|
||||
let active = true;
|
||||
const dispose = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
signal.removeEventListener("abort", dispose);
|
||||
stop();
|
||||
};
|
||||
signal.addEventListener("abort", dispose, { once: true });
|
||||
return {
|
||||
update(next) {
|
||||
signal.throwIfAborted();
|
||||
if (!active) {
|
||||
throw new Error("This Settings component has ended.");
|
||||
}
|
||||
setState({ value: next });
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mountSection: (container, initial) =>
|
||||
mount(container, initial, (props) => (
|
||||
<SettingsSection title={props().title}>{props().content}</SettingsSection>
|
||||
)),
|
||||
mountRow: (container, initial) =>
|
||||
mount(
|
||||
container,
|
||||
initial,
|
||||
(props) => (
|
||||
<SettingsRow
|
||||
title={props().title}
|
||||
description={props().description}
|
||||
control={props().control}
|
||||
/>
|
||||
),
|
||||
true,
|
||||
),
|
||||
mountStatus: (container, initial) =>
|
||||
mount(container, initial, (props) => (
|
||||
<SettingsStatus kind={props().kind} label={props().label} />
|
||||
)),
|
||||
mountValue: (container, initial) =>
|
||||
mount(container, initial, (props) => (
|
||||
<SettingsValue value={props().value} mono={props().mono} />
|
||||
)),
|
||||
mountNavigation: (container, initial) =>
|
||||
mount(
|
||||
container,
|
||||
initial,
|
||||
(props) => (
|
||||
<SettingsNavRow
|
||||
title={props().title}
|
||||
onClick={() => {
|
||||
signal.throwIfAborted();
|
||||
props().onClick();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
true,
|
||||
),
|
||||
mountLobster: (container, initial) =>
|
||||
mount(container, initial, (props) => {
|
||||
const look = createMemo(() => createLobsterPetLook(lobsterPetSeed(props().seed)));
|
||||
return (
|
||||
<div style={`${lobsterLookStyle(look())};display:contents`}>
|
||||
<LobsterSvg
|
||||
look={look()}
|
||||
sleeping={props().sleeping}
|
||||
reading={props().reading}
|
||||
grumpy={props().grumpy}
|
||||
standalone={props().standalone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { ControlUiSettingsView } from "../../../src/plugin-sdk/control-ui.js";
|
||||
import type { ControlUiPluginDistribution } from "./control-ui-distribution.ts";
|
||||
import { loadControlUiPluginAssets } from "./control-ui-loader.ts";
|
||||
import { ControlUiPluginRuntime } from "./control-ui-runtime.ts";
|
||||
|
||||
// mock-isolation: synthetic packaged modules replace the real loader's DOM/network acquisition and host rendering imports.
|
||||
vi.mock("./control-ui-loader.ts", () => ({ loadControlUiPluginAssets: vi.fn() }));
|
||||
const packaged = vi.hoisted(() => [] as ControlUiPluginDistribution[]);
|
||||
vi.mock("./control-ui-distribution.ts", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./control-ui-distribution.ts")>()),
|
||||
controlUiPluginDistributions: packaged,
|
||||
}));
|
||||
afterEach(() => {
|
||||
packaged.length = 0;
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
const placement = { section: "memory", panel: "overview" } as const;
|
||||
function fixture() {
|
||||
packaged.push({
|
||||
pluginId: "fixture",
|
||||
revision: "built-revision",
|
||||
bundledDistribution: { packageName: "fixture", packageVersion: "1.0.0" },
|
||||
activation: "settings-only",
|
||||
settingsViews: [placement],
|
||||
entry: "/packaged/index.js",
|
||||
styles: ["/packaged/index.css"],
|
||||
imports: [],
|
||||
});
|
||||
const view: ControlUiSettingsView = { placement, mount: vi.fn() };
|
||||
const activate = vi.fn();
|
||||
const stop = vi.fn();
|
||||
const style = document.createElement("link");
|
||||
style.media = "not all";
|
||||
const module = {
|
||||
pluginId: "fixture",
|
||||
settingsViews: [view],
|
||||
default: { id: "fixture", activate },
|
||||
};
|
||||
vi.mocked(loadControlUiPluginAssets).mockImplementation(
|
||||
async (_id, _urls, _signal, retain, styles) => {
|
||||
retain(stop);
|
||||
styles?.push(style);
|
||||
return module;
|
||||
},
|
||||
);
|
||||
const runtime = new ControlUiPluginRuntime(() => {
|
||||
throw new Error("Settings loading must not read the active catalog");
|
||||
});
|
||||
return { runtime, view, activate, stop, style, module };
|
||||
}
|
||||
it("loads packaged Settings independently and retires its assets with the runtime", async () => {
|
||||
const f = fixture();
|
||||
const loaded = await f.runtime.loadSettingsView(placement, new AbortController().signal);
|
||||
expect(loaded.view).toBe(f.view);
|
||||
expect(f.style.media).toBe("all");
|
||||
expect(f.activate).not.toHaveBeenCalled();
|
||||
expect(f.runtime.hasPlugins).toBe(false);
|
||||
f.runtime.dispose();
|
||||
expect(loaded.signal.aborted).toBe(true);
|
||||
expect(f.stop).toHaveBeenCalledOnce();
|
||||
loaded.dispose();
|
||||
expect(f.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects ambiguous packaged placement owners before loading code", async () => {
|
||||
const f = fixture();
|
||||
packaged.push({ ...packaged[0]!, pluginId: "duplicate" });
|
||||
await expect(f.runtime.loadSettingsView(placement, new AbortController().signal)).rejects.toThrow(
|
||||
"one packaged owner",
|
||||
);
|
||||
expect(loadControlUiPluginAssets).not.toHaveBeenCalled();
|
||||
f.runtime.dispose();
|
||||
});
|
||||
it.each(["owner", "placement", "duplicate"])(
|
||||
"rejects a built module with a mismatched %s and disposes its styles",
|
||||
async (kind) => {
|
||||
const f = fixture();
|
||||
if (kind === "owner") {
|
||||
f.module.pluginId = "another-plugin";
|
||||
}
|
||||
if (kind === "placement") {
|
||||
f.module.settingsViews = [
|
||||
{
|
||||
...f.view,
|
||||
placement: { section: "other", panel: "overview" },
|
||||
} as unknown as ControlUiSettingsView,
|
||||
];
|
||||
}
|
||||
if (kind === "duplicate") {
|
||||
f.module.settingsViews = [f.view, f.view];
|
||||
}
|
||||
await expect(
|
||||
f.runtime.loadSettingsView(placement, new AbortController().signal),
|
||||
).rejects.toThrow();
|
||||
expect(f.style.media).toBe("not all");
|
||||
expect(f.stop).toHaveBeenCalledOnce();
|
||||
f.runtime.dispose();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,181 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsSnapshot,
|
||||
} from "../../../src/plugin-sdk/control-ui-settings.js";
|
||||
import type { ApplicationContext } from "../app/context.ts";
|
||||
import { mountSolid } from "../test-helpers/mount-solid.ts";
|
||||
import { waitForSolid } from "../test-helpers/solid-settle.ts";
|
||||
import { ControlUiSettingsPresentation } from "./control-ui-settings-view.solid.tsx";
|
||||
|
||||
function fixture(
|
||||
options: {
|
||||
scopes?: string[];
|
||||
updateThrows?: boolean;
|
||||
disposeThrows?: boolean;
|
||||
retireDuringMount?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
let snapshot: ControlUiSettingsSnapshot = {
|
||||
agentId: "main",
|
||||
agentsLoading: false,
|
||||
agentsError: null,
|
||||
engine: { kind: "default", id: "memory-core", availability: "enabled" },
|
||||
connected: true,
|
||||
connectionEpoch: 1,
|
||||
routeIntent: "overview",
|
||||
locale: "en",
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
const runtime = new AbortController();
|
||||
const contexts: ControlUiSettingsContext[] = [];
|
||||
const viewDisposers: ReturnType<typeof vi.fn>[] = [];
|
||||
const viewUpdates: ReturnType<typeof vi.fn>[] = [];
|
||||
const assetDisposers: ReturnType<typeof vi.fn>[] = [];
|
||||
const request = vi.fn(async () => "current");
|
||||
const navigate = vi.fn();
|
||||
const application = {
|
||||
gateway: {
|
||||
snapshot: {
|
||||
phase: "connected",
|
||||
hello: { auth: { scopes: options.scopes ?? ["operator.read"] } },
|
||||
client: { request },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
reportError: vi.fn(),
|
||||
async loadSettingsView(_placement: unknown, signal: AbortSignal) {
|
||||
const dispose = vi.fn();
|
||||
assetDisposers.push(dispose);
|
||||
return {
|
||||
signal: AbortSignal.any([signal, runtime.signal]),
|
||||
dispose,
|
||||
view: {
|
||||
placement: { section: "memory", panel: "overview" },
|
||||
mount(_container: HTMLElement, context: ControlUiSettingsContext) {
|
||||
contexts.push(context);
|
||||
const stop = vi.fn(() => {
|
||||
if (options.disposeThrows) {
|
||||
throw new Error("dispose failed");
|
||||
}
|
||||
});
|
||||
viewDisposers.push(stop);
|
||||
if (options.retireDuringMount && contexts.length === 1) {
|
||||
change({ agentId: "writer" });
|
||||
}
|
||||
const update = vi.fn(() => {
|
||||
if (options.updateThrows) {
|
||||
throw new Error("update failed");
|
||||
}
|
||||
});
|
||||
viewUpdates.push(update);
|
||||
return { update, dispose: stop };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
function change(patch: Partial<ControlUiSettingsSnapshot>) {
|
||||
snapshot = { ...snapshot, ...patch };
|
||||
for (const notify of listeners) {
|
||||
notify();
|
||||
}
|
||||
}
|
||||
const mounted = mountSolid(() => (
|
||||
<ControlUiSettingsPresentation
|
||||
application={application}
|
||||
placement={{ section: "memory", panel: "overview" }}
|
||||
readSnapshot={() => snapshot}
|
||||
subscribe={(notify) => {
|
||||
listeners.add(notify);
|
||||
return () => listeners.delete(notify);
|
||||
}}
|
||||
navigate={navigate}
|
||||
refreshAgents={vi.fn()}
|
||||
/>
|
||||
));
|
||||
return {
|
||||
...mounted,
|
||||
contexts,
|
||||
viewDisposers,
|
||||
viewUpdates,
|
||||
assetDisposers,
|
||||
request,
|
||||
navigate,
|
||||
runtime,
|
||||
change,
|
||||
};
|
||||
}
|
||||
|
||||
it("checks actual Gateway operator scopes before dispatch", async () => {
|
||||
const f = fixture({ scopes: [] });
|
||||
await waitForSolid(() => expect(f.contexts).toHaveLength(1));
|
||||
await expect(f.contexts[0]!.request("doctor.memory.status")).rejects.toThrow("operator access");
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
it("cleans mounted views and assets when the first update throws", async () => {
|
||||
const f = fixture({ updateThrows: true, disposeThrows: true });
|
||||
await waitForSolid(() => expect(f.assetDisposers[0]).toHaveBeenCalledTimes(1));
|
||||
expect(f.viewDisposers[0]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("retires a later failed update before callbacks or pending replies can escape", async () => {
|
||||
const f = fixture();
|
||||
await waitForSolid(() => expect(f.viewUpdates[0]).toHaveBeenCalledTimes(1));
|
||||
const old = f.contexts[0]!;
|
||||
const pending = old.request("doctor.memory.status");
|
||||
f.viewUpdates[0]!.mockImplementationOnce(() => {
|
||||
throw new Error("later update failed");
|
||||
});
|
||||
expect(() => f.change({ locale: "fr" })).not.toThrow();
|
||||
expect(old.signal.aborted).toBe(true);
|
||||
expect(f.container.textContent).toBe("later update failed");
|
||||
await expect(pending).rejects.toThrow();
|
||||
await expect(old.request("doctor.memory.status")).rejects.toThrow();
|
||||
expect(() => old.navigate("settings")).toThrow();
|
||||
expect(f.request).toHaveBeenCalledTimes(1);
|
||||
expect(f.navigate).not.toHaveBeenCalled();
|
||||
f.unmount();
|
||||
expect(f.viewDisposers[0]).toHaveBeenCalledTimes(1);
|
||||
expect(f.assetDisposers[0]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("retires retained callbacks and disposes exactly once on runtime shutdown", async () => {
|
||||
const f = fixture();
|
||||
await waitForSolid(() => expect(f.contexts).toHaveLength(1));
|
||||
const old = f.contexts[0]!;
|
||||
f.runtime.abort();
|
||||
expect(old.signal.aborted).toBe(true);
|
||||
expect(f.viewDisposers[0]).toHaveBeenCalledTimes(1);
|
||||
expect(f.assetDisposers[0]).toHaveBeenCalledTimes(1);
|
||||
await expect(old.request("doctor.memory.status")).rejects.toThrow();
|
||||
expect(() => old.navigate("settings")).toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
expect(f.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["agent", "engine", "epoch", "route"])(
|
||||
"rejects a retained callback after %s changes",
|
||||
async (kind) => {
|
||||
const f = fixture();
|
||||
await waitForSolid(() => expect(f.contexts).toHaveLength(1));
|
||||
const old = f.contexts[0]!;
|
||||
const patch: Partial<ControlUiSettingsSnapshot> =
|
||||
kind === "agent"
|
||||
? { agentId: "writer" }
|
||||
: kind === "engine"
|
||||
? { engine: { kind: "pinned", id: "custom-memory", availability: "unknown" } }
|
||||
: kind === "epoch"
|
||||
? { connectionEpoch: 2 }
|
||||
: { routeIntent: "overview?agent=writer" };
|
||||
f.change(patch);
|
||||
await expect(old.request("doctor.memory.status")).rejects.toThrow();
|
||||
expect(() => old.navigate("settings")).toThrow();
|
||||
expect(f.viewDisposers[0]).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
it("disposes a view that synchronously retires its authority while mounting", async () => {
|
||||
const f = fixture({ retireDuringMount: true });
|
||||
await waitForSolid(() => expect(f.contexts).toHaveLength(2));
|
||||
expect(f.viewDisposers[0]).toHaveBeenCalledTimes(1);
|
||||
expect(f.assetDisposers[0]).toHaveBeenCalledTimes(1);
|
||||
expect(f.contexts[0]!.signal.aborted).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createEffect, onSettled } from "solid-js";
|
||||
import type {
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsPlacement,
|
||||
ControlUiSettingsSnapshot,
|
||||
} from "../../../src/plugin-sdk/control-ui-settings.js";
|
||||
import type { ApplicationContext } from "../app/context.ts";
|
||||
import { hasOperatorReadAccess } from "../app/operator-access.ts";
|
||||
import { formatUiError } from "../lib/format-error.ts";
|
||||
import { formatRelativeTimestamp } from "../lib/format.ts";
|
||||
import { createControlUiSettingsComponents } from "./control-ui-settings-components.solid.tsx";
|
||||
|
||||
function authority(snapshot: ControlUiSettingsSnapshot): string {
|
||||
return JSON.stringify([
|
||||
snapshot.agentId,
|
||||
snapshot.engine,
|
||||
snapshot.connected,
|
||||
snapshot.connectionEpoch,
|
||||
snapshot.routeIntent,
|
||||
]);
|
||||
}
|
||||
|
||||
export function ControlUiSettingsPresentation(props: {
|
||||
application: ApplicationContext;
|
||||
placement: ControlUiSettingsPlacement;
|
||||
readSnapshot: () => ControlUiSettingsSnapshot;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
navigate: ControlUiSettingsContext["navigate"];
|
||||
refreshAgents: () => void;
|
||||
}) {
|
||||
let container: HTMLDivElement | undefined;
|
||||
let active = true;
|
||||
let owner:
|
||||
| {
|
||||
abort: AbortController;
|
||||
identity: string;
|
||||
update?: (snapshot: ControlUiSettingsSnapshot) => void;
|
||||
dispose?: () => void;
|
||||
}
|
||||
| undefined;
|
||||
const retire = () => {
|
||||
const previous = owner;
|
||||
owner = undefined;
|
||||
previous?.abort.abort();
|
||||
try {
|
||||
previous?.dispose?.();
|
||||
} catch (error) {
|
||||
props.application.plugins.reportError("host", error);
|
||||
} finally {
|
||||
container?.replaceChildren();
|
||||
}
|
||||
};
|
||||
const fail = (error: unknown) => {
|
||||
retire();
|
||||
if (container) {
|
||||
container.textContent = formatUiError(error, "Settings presentation could not load.");
|
||||
}
|
||||
};
|
||||
const sync = () => {
|
||||
if (!active || !container) {
|
||||
return;
|
||||
}
|
||||
const snapshot = props.readSnapshot();
|
||||
const identity = authority(snapshot);
|
||||
if (owner?.identity === identity) {
|
||||
try {
|
||||
owner.update?.(snapshot);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
retire();
|
||||
const next: NonNullable<typeof owner> = { abort: new AbortController(), identity };
|
||||
let lifetime = next.abort.signal;
|
||||
owner = next;
|
||||
const current = () => {
|
||||
lifetime.throwIfAborted();
|
||||
if (!active || owner !== next || authority(props.readSnapshot()) !== identity) {
|
||||
throw new Error("This Settings presentation has ended.");
|
||||
}
|
||||
};
|
||||
void props.application.plugins
|
||||
.loadSettingsView(props.placement, next.abort.signal)
|
||||
.then((loaded) => {
|
||||
if (owner !== next || next.abort.signal.aborted) {
|
||||
loaded.dispose();
|
||||
return;
|
||||
}
|
||||
lifetime = loaded.signal;
|
||||
let assetsDisposed = false;
|
||||
const disposeAssets = () => {
|
||||
if (assetsDisposed) {
|
||||
return;
|
||||
}
|
||||
assetsDisposed = true;
|
||||
loaded.dispose();
|
||||
};
|
||||
next.dispose = disposeAssets;
|
||||
current();
|
||||
const context: ControlUiSettingsContext = {
|
||||
props: props.readSnapshot(),
|
||||
signal: lifetime,
|
||||
components: createControlUiSettingsComponents(lifetime),
|
||||
async request<T>(method: string, params?: unknown) {
|
||||
current();
|
||||
const gateway = props.application.gateway.snapshot;
|
||||
if (
|
||||
!gateway.client ||
|
||||
gateway.phase !== "connected" ||
|
||||
!hasOperatorReadAccess(gateway.hello?.auth ?? null)
|
||||
) {
|
||||
throw new Error("The Gateway is offline or operator access is unavailable.");
|
||||
}
|
||||
const result = await gateway.client.request<T>(method, params);
|
||||
current();
|
||||
return result;
|
||||
},
|
||||
navigate(destination) {
|
||||
current();
|
||||
props.navigate(destination);
|
||||
},
|
||||
refreshAgents() {
|
||||
current();
|
||||
props.refreshAgents();
|
||||
},
|
||||
formatRelativeTimestamp(timestamp) {
|
||||
current();
|
||||
return formatRelativeTimestamp(timestamp);
|
||||
},
|
||||
formatError(error) {
|
||||
current();
|
||||
return formatUiError(error);
|
||||
},
|
||||
};
|
||||
const mounted = loaded.view.mount(container!, context);
|
||||
let disposed = false;
|
||||
next.dispose = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
try {
|
||||
mounted.dispose();
|
||||
} finally {
|
||||
disposeAssets();
|
||||
}
|
||||
};
|
||||
if (lifetime.aborted || owner !== next) {
|
||||
next.dispose();
|
||||
return;
|
||||
}
|
||||
lifetime.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (owner === next) {
|
||||
retire();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
next.update = mounted.update;
|
||||
mounted.update(props.readSnapshot());
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (owner === next && !next.abort.signal.aborted) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
// The imperative plugin view receives live host props; transport owners also
|
||||
// notify synchronously so a batched disconnect/reconnect retires old authority.
|
||||
createEffect(props.readSnapshot, sync);
|
||||
onSettled(() => {
|
||||
const stop = props.subscribe(sync);
|
||||
sync();
|
||||
return () => {
|
||||
active = false;
|
||||
stop();
|
||||
retire();
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div
|
||||
style={{ display: "contents" }}
|
||||
ref={(node) => {
|
||||
container = node;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { once } from "node:events";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
WebSocket,
|
||||
WebSocketServer,
|
||||
} from "../../../packages/gateway-client/src/websocket.test-support.ts";
|
||||
import {
|
||||
buildMinimalGatewayHelloOkPayload,
|
||||
closeMinimalGatewayServer,
|
||||
parseMinimalGatewayRequestFrame,
|
||||
sendMinimalGatewayConnectChallenge,
|
||||
sendMinimalGatewayResponse,
|
||||
} from "../../../src/gateway/minimal-gateway.test-helpers.js";
|
||||
import type {
|
||||
ControlUiSettingsContext,
|
||||
ControlUiSettingsSnapshot,
|
||||
} from "../../../src/plugin-sdk/control-ui-settings.js";
|
||||
import { createDeferred, withinTest } from "../../../test/helpers/promise.js";
|
||||
import { GatewayBrowserClient, type GatewayHelloOk } from "../api/gateway.ts";
|
||||
import type { ApplicationContext } from "../app/context.ts";
|
||||
import { mountSolid } from "../test-helpers/mount-solid.ts";
|
||||
import { ControlUiSettingsPresentation } from "./control-ui-settings-view.solid.tsx";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
async function transportFixture(scopes: string[], signal: AbortSignal) {
|
||||
vi.stubGlobal("WebSocket", WebSocket);
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const frames: Array<{ id: string; method: string; params: unknown }> = [];
|
||||
const held = createDeferred<{ socket: WebSocket; id: string }>();
|
||||
server.on("connection", (socket) => {
|
||||
sendMinimalGatewayConnectChallenge(socket);
|
||||
socket.on("message", (raw) => {
|
||||
const frame = parseMinimalGatewayRequestFrame(raw);
|
||||
if (frame.type !== "req" || !frame.id || !frame.method) {
|
||||
return;
|
||||
}
|
||||
frames.push({ id: frame.id, method: frame.method, params: frame.params });
|
||||
if (frame.method === "connect") {
|
||||
sendMinimalGatewayResponse(
|
||||
socket,
|
||||
frame.id,
|
||||
buildMinimalGatewayHelloOkPayload({
|
||||
auth: { role: "operator", scopes },
|
||||
}),
|
||||
);
|
||||
} else if (frame.params?.hold === true) {
|
||||
held.resolve({ socket, id: frame.id });
|
||||
} else {
|
||||
sendMinimalGatewayResponse(socket, frame.id, { marker: frame.method });
|
||||
}
|
||||
});
|
||||
});
|
||||
let client: GatewayBrowserClient | undefined;
|
||||
let mounted: ReturnType<typeof mountSolid> | undefined;
|
||||
const close = async () => {
|
||||
mounted?.unmount();
|
||||
client?.stop();
|
||||
await closeMinimalGatewayServer(server);
|
||||
};
|
||||
try {
|
||||
await withinTest(once(server, "listening"), signal);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected a loopback listener");
|
||||
}
|
||||
const hello = createDeferred<GatewayHelloOk>();
|
||||
client = new GatewayBrowserClient({
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
onHello: hello.resolve,
|
||||
});
|
||||
client.start();
|
||||
const connected = await withinTest(hello.promise, signal);
|
||||
let snapshot: ControlUiSettingsSnapshot = {
|
||||
agentId: "main",
|
||||
agentsLoading: false,
|
||||
agentsError: null,
|
||||
engine: { kind: "default", id: "memory-core", availability: "enabled" },
|
||||
connected: true,
|
||||
connectionEpoch: 1,
|
||||
routeIntent: "overview",
|
||||
locale: "en",
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
const runtime = new AbortController();
|
||||
const context = createDeferred<ControlUiSettingsContext>();
|
||||
const application = {
|
||||
gateway: { snapshot: { phase: "connected", hello: connected, client } },
|
||||
plugins: {
|
||||
reportError: (error: unknown) => {
|
||||
throw error;
|
||||
},
|
||||
async loadSettingsView(_placement: unknown, mountSignal: AbortSignal) {
|
||||
return {
|
||||
signal: AbortSignal.any([mountSignal, runtime.signal]),
|
||||
dispose() {},
|
||||
view: {
|
||||
placement: { section: "memory", panel: "overview" },
|
||||
mount(_container: HTMLElement, value: ControlUiSettingsContext) {
|
||||
context.resolve(value);
|
||||
return { update() {}, dispose() {} };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
mounted = mountSolid(() => (
|
||||
<ControlUiSettingsPresentation
|
||||
application={application}
|
||||
placement={{ section: "memory", panel: "overview" }}
|
||||
readSnapshot={() => snapshot}
|
||||
subscribe={(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}}
|
||||
navigate={() => {}}
|
||||
refreshAgents={() => {}}
|
||||
/>
|
||||
));
|
||||
return {
|
||||
client,
|
||||
frames,
|
||||
held,
|
||||
close,
|
||||
context: await withinTest(context.promise, signal),
|
||||
retire(cause: string) {
|
||||
if (cause === "runtime") {
|
||||
runtime.abort();
|
||||
} else {
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
...(cause === "agent" ? { agentId: "writer" } : { connectionEpoch: 2 }),
|
||||
};
|
||||
for (const notify of listeners) {
|
||||
notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
it.for(["agent", "connection", "runtime"])(
|
||||
"fences real transport dispatch and replies after %s retirement",
|
||||
async (cause, { signal }) => {
|
||||
const f = await transportFixture(["operator.read"], signal);
|
||||
try {
|
||||
await expect(f.context.request("doctor.memory.status", { agentId: "main" })).resolves.toEqual(
|
||||
{ marker: "doctor.memory.status" },
|
||||
);
|
||||
expect(
|
||||
f.frames
|
||||
.filter((frame) => frame.method === "doctor.memory.status")
|
||||
.map(({ params }) => params),
|
||||
).toEqual([{ agentId: "main" }]);
|
||||
const pending = f.context.request("doctor.memory.status", { agentId: "main", hold: true });
|
||||
const reply = await withinTest(f.held.promise, signal);
|
||||
f.retire(cause);
|
||||
expect(f.context.signal.aborted).toBe(true);
|
||||
await expect(
|
||||
f.context.request("doctor.memory.status", { agentId: "main", probe: true }),
|
||||
).rejects.toThrow();
|
||||
// An acknowledged frame on the still-connected socket orders the server ledger
|
||||
// after the rejected callback; no delay or mocked client can hide a dispatch.
|
||||
await f.client.request("health", { barrier: true });
|
||||
expect(f.frames.filter((frame) => frame.method === "doctor.memory.status")).toHaveLength(2);
|
||||
const rejected = expect(pending).rejects.toThrow();
|
||||
sendMinimalGatewayResponse(reply.socket, reply.id, { marker: "retired response" });
|
||||
await rejected;
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an unauthorized caller before any real transport request", async ({ signal }) => {
|
||||
const f = await transportFixture([], signal);
|
||||
try {
|
||||
await expect(f.context.request("doctor.memory.status")).rejects.toThrow("operator access");
|
||||
await f.client.request("health", { barrier: true });
|
||||
expect(f.frames.map(({ method }) => method)).toEqual(["connect", "health"]);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -523,6 +523,7 @@ openclaw-appearance-background .settings-background-preview .session-background_
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.settings-row-mount + .settings-row-mount > .settings-row,
|
||||
.settings-row + .settings-row,
|
||||
.settings-row + .settings-subrows,
|
||||
.settings-subrows + .settings-row {
|
||||
|
||||
@@ -595,6 +595,7 @@ export async function startControlUiE2eServer(
|
||||
{ createServer },
|
||||
{ controlUiLocaleModulesPlugin },
|
||||
{ controlUiSolidPlugin },
|
||||
{ controlUiPluginDistributionsPlugin },
|
||||
{
|
||||
commonJsOptimizeDeps,
|
||||
resolveExternalPackageAliasesForVite,
|
||||
@@ -605,6 +606,7 @@ export async function startControlUiE2eServer(
|
||||
import("vite"),
|
||||
import("../../config/control-ui-locales.ts"),
|
||||
import("../../config/control-ui-solid.ts"),
|
||||
import("../../config/control-ui-plugin-distributions.ts"),
|
||||
import("../../vite.config.ts"),
|
||||
]);
|
||||
const repoRoot = resolveRepoRoot();
|
||||
@@ -623,7 +625,11 @@ export async function startControlUiE2eServer(
|
||||
include: ["ipaddr.js", ...commonJsOptimizeDeps],
|
||||
},
|
||||
publicDir: path.join(uiRoot, "public"),
|
||||
plugins: [controlUiSolidPlugin(), controlUiLocaleModulesPlugin()],
|
||||
plugins: [
|
||||
controlUiSolidPlugin(),
|
||||
controlUiLocaleModulesPlugin(),
|
||||
controlUiPluginDistributionsPlugin(repoRoot, new Set()),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: "json5", replacement: json5EsmPath },
|
||||
|
||||
@@ -8,4 +8,3 @@ import "../styles/chat/composer.css";
|
||||
import "../styles/config.css";
|
||||
import "../styles/usage.css";
|
||||
import "../styles/dreams.css";
|
||||
import "../styles/memory-overview.css";
|
||||
Reference in new issue
Block a user