feat(hermes): replace Prompts entry with Memory panel

Hermes has no slash-prompt concept (templates live as Skills), so the
Prompts tab for the Hermes app was always empty. Swap the toolbar Book
button for a Brain button that opens a new Memory panel editing
~/.hermes/memories/{MEMORY,USER}.md — Hermes' first-class memory store
which its Web UI exposes only as on/off toggles, never as an editor.

The panel shows each file in its own tab with a character-budget bar
read from config.yaml's nested memory.* section (memory_char_limit /
user_char_limit, default 2200 / 1375). Edits are written atomically;
Hermes picks them up on the next session start per MemoryStore.

Also extract useDarkMode to src/hooks/useDarkMode.ts — the codebase
already repeats the same MutationObserver pattern in 12+ places; this
PR introduces the shared hook and uses it once, leaving the migration
of the other copies to a follow-up.
This commit is contained in:
Jason
2026-04-19 21:49:27 +08:00
parent 088b47b08a
commit acc6d795e4
12 changed files with 552 additions and 6 deletions
+11 -4
View File
@@ -14,6 +14,7 @@ import {
Minimize2,
X,
Book,
Brain,
Wrench,
RefreshCw,
History,
@@ -89,6 +90,7 @@ import ToolsPanel from "@/components/openclaw/ToolsPanel";
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
import HermesHealthBanner from "@/components/hermes/HermesHealthBanner";
import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel";
type View =
| "providers"
@@ -103,7 +105,8 @@ type View =
| "workspace"
| "openclawEnv"
| "openclawTools"
| "openclawAgents";
| "openclawAgents"
| "hermesMemory";
interface WebDavSyncStatusUpdatedPayload {
source?: string;
@@ -147,6 +150,7 @@ const VALID_VIEWS: View[] = [
"openclawEnv",
"openclawTools",
"openclawAgents",
"hermesMemory",
];
const getInitialView = (): View => {
@@ -909,6 +913,8 @@ function App() {
appId={activeApp}
/>
);
case "hermesMemory":
return <HermesMemoryPanel />;
case "skills":
return (
<UnifiedSkillsPanel
@@ -1171,6 +1177,7 @@ function App() {
{currentView === "openclawTools" && t("openclaw.tools.title")}
{currentView === "openclawAgents" &&
t("openclaw.agents.title")}
{currentView === "hermesMemory" && t("hermes.memory.title")}
</h1>
</div>
) : (
@@ -1384,11 +1391,11 @@ function App() {
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
onClick={() => setCurrentView("hermesMemory")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
title={t("hermes.memory.title")}
>
<Book className="w-4 h-4" />
<Brain className="w-4 h-4" />
</Button>
<Button
variant="ghost"
+155
View File
@@ -0,0 +1,155 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import MarkdownEditor from "@/components/MarkdownEditor";
import {
useHermesMemory,
useHermesMemoryLimits,
useOpenHermesWebUI,
useSaveHermesMemory,
} from "@/hooks/useHermes";
import { useDarkMode } from "@/hooks/useDarkMode";
import type { HermesMemoryKind } from "@/types";
import { cn } from "@/lib/utils";
interface MemoryTabPaneProps {
kind: HermesMemoryKind;
limit: number;
enabled: boolean;
}
const MemoryTabPane: React.FC<MemoryTabPaneProps> = ({
kind,
limit,
enabled,
}) => {
const { t } = useTranslation();
const darkMode = useDarkMode();
const { data, isLoading } = useHermesMemory(kind, true);
const saveMutation = useSaveHermesMemory();
const [content, setContent] = useState("");
const [loaded, setLoaded] = useState(false);
// Hydrate local dirty buffer from query data only on first load. Later
// refetches (e.g. after a successful save) must not clobber in-flight user
// edits — the caller owns `content` until they click Save again.
useEffect(() => {
if (!loaded && data !== undefined) {
setContent(data);
setLoaded(true);
}
}, [data, loaded]);
const handleSave = async () => {
try {
await saveMutation.mutateAsync({ kind, content });
toast.success(t("hermes.memory.saveSuccess"));
} catch {
// useSaveHermesMemory already surfaces a localized error toast.
}
};
const charCount = content.length;
const isOver = charCount > limit;
return (
<div className="flex flex-col gap-3">
{!enabled && (
<div className="text-sm text-amber-700 dark:text-amber-400 px-3 py-2 rounded-md bg-amber-500/10 border border-amber-500/30">
{t("hermes.memory.disabled")}
</div>
)}
{isLoading && !loaded ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
{t("prompts.loading")}
</div>
) : (
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={darkMode}
minHeight="calc(100vh - 320px)"
/>
)}
<div className="flex items-center justify-between gap-3 text-sm">
<span
className={cn(
"text-muted-foreground",
isOver && "text-red-600 dark:text-red-400 font-medium",
)}
>
{t("hermes.memory.usage", { current: charCount, limit })}
{isOver ? `${t("hermes.memory.overLimit")}` : ""}
</span>
<div className="flex items-center gap-3">
<span className="hidden md:inline text-xs text-muted-foreground">
{t("hermes.memory.runtimeNote")}
</span>
<Button
onClick={handleSave}
disabled={saveMutation.isPending || !loaded}
>
{saveMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
</div>
);
};
const HermesMemoryPanel: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HermesMemoryKind>("memory");
const openHermesWebUI = useOpenHermesWebUI();
const { data: limits } = useHermesMemoryLimits(true);
const memoryLimit = limits?.memory ?? 2200;
const userLimit = limits?.user ?? 1375;
const memoryEnabled = limits?.memoryEnabled ?? true;
const userEnabled = limits?.userEnabled ?? true;
return (
<div className="flex flex-col h-full">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as HermesMemoryKind)}
className="flex-1 flex flex-col"
>
<div className="px-6 pt-4 flex items-center justify-between gap-3 flex-wrap">
<TabsList>
<TabsTrigger value="memory">
{t("hermes.memory.agentTab")}
</TabsTrigger>
<TabsTrigger value="user">{t("hermes.memory.userTab")}</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={() => void openHermesWebUI("/config")}
>
<ExternalLink className="w-3.5 h-3.5 mr-1" />
{t("hermes.memory.openConfig")}
</Button>
</div>
<TabsContent value="memory" className="flex-1 px-6 pb-4 mt-4">
<MemoryTabPane
kind="memory"
limit={memoryLimit}
enabled={memoryEnabled}
/>
</TabsContent>
<TabsContent value="user" className="flex-1 px-6 pb-4 mt-4">
<MemoryTabPane kind="user" limit={userLimit} enabled={userEnabled} />
</TabsContent>
</Tabs>
</div>
);
};
export default HermesMemoryPanel;
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
/**
* Subscribe to the presence of `class="dark"` on `<html>`. The theme-provider
* toggles this class whenever the effective theme changes (including when the
* OS changes theme while the app is in "system" mode), so observing the class
* is the simplest way to drive components that need a boolean `darkMode` prop
* (e.g. CodeMirror-based editors that don't consume the theme context).
*/
export function useDarkMode(): boolean {
const [isDark, setIsDark] = useState(() =>
typeof document !== "undefined"
? document.documentElement.classList.contains("dark")
: false,
);
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return isDark;
}
+56 -1
View File
@@ -1,9 +1,15 @@
import { useCallback } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { hermesApi } from "@/lib/api/hermes";
import { providersApi } from "@/lib/api/providers";
import type { HermesMemoryKind } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
/**
@@ -22,6 +28,8 @@ export const hermesKeys = {
liveProviderIds: ["hermes", "liveProviderIds"] as const,
modelConfig: ["hermes", "modelConfig"] as const,
health: ["hermes", "health"] as const,
memory: (kind: HermesMemoryKind) => ["hermes", "memory", kind] as const,
memoryLimits: ["hermes", "memoryLimits"] as const,
};
/**
@@ -66,10 +74,57 @@ export function useHermesHealth(enabled: boolean) {
});
}
export function useHermesMemory(kind: HermesMemoryKind, enabled: boolean) {
return useQuery({
queryKey: hermesKeys.memory(kind),
queryFn: () => hermesApi.getMemory(kind),
enabled,
});
}
export function useHermesMemoryLimits(enabled: boolean) {
return useQuery({
queryKey: hermesKeys.memoryLimits,
queryFn: () => hermesApi.getMemoryLimits(),
staleTime: 60_000,
enabled,
});
}
// ============================================================
// Mutation hooks
// ============================================================
/**
* Save a Hermes memory file atomically and refresh the corresponding query.
* Error toasts are emitted here so caller components don't need their own
* try/catch; success toasts are intentionally left to the caller (to pick
* the right localized message per tab).
*/
export function useSaveHermesMemory() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: ({
kind,
content,
}: {
kind: HermesMemoryKind;
content: string;
}) => hermesApi.setMemory(kind, content),
onSuccess: async (_data, variables) => {
await queryClient.invalidateQueries({
queryKey: hermesKeys.memory(variables.kind),
});
},
onError: (error) => {
toast.error(t("hermes.memory.saveFailed"), {
description: extractErrorMessage(error) || undefined,
});
},
});
}
/**
* Returns a handler that probes the local Hermes Web UI, opens it in the
* system browser, and surfaces a localized toast on failure. Callers only
+13
View File
@@ -1675,6 +1675,19 @@
"parseFailed": "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.",
"configNotFound": "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.",
"envParseFailed": "The .env file could not be parsed."
},
"memory": {
"title": "Memory",
"agentTab": "Agent Memory (MEMORY.md)",
"userTab": "User Profile (USER.md)",
"usage": "{{current}} / {{limit}} characters",
"overLimit": "Over budget — Hermes will truncate on next load",
"disabled": "Memory is disabled in config.yaml. Enable it in Hermes config for edits to take effect.",
"saveSuccess": "Memory saved",
"saveFailed": "Failed to save memory",
"loadFailed": "Failed to read memory file",
"openConfig": "Adjust limits in Hermes Web UI",
"runtimeNote": "Changes apply on Hermes restart or new session."
}
},
"env": {
+13
View File
@@ -1675,6 +1675,19 @@
"parseFailed": "config.yaml を有効な YAML として解析できません。ここで編集する前にファイルを修正してください。",
"configNotFound": "Hermes config.yaml が見つかりません。~/.hermes/config.yaml に作成するか、設定でパスを構成してください。",
"envParseFailed": ".env ファイルを解析できません。"
},
"memory": {
"title": "メモリ",
"agentTab": "エージェント記憶 (MEMORY.md)",
"userTab": "ユーザープロファイル (USER.md)",
"usage": "{{current}} / {{limit}} 文字",
"overLimit": "上限を超えました。Hermes は次回読み込み時に切り詰めます",
"disabled": "Memory が config.yaml で無効化されています。Hermes 設定で有効化すると編集内容が反映されます。",
"saveSuccess": "記憶を保存しました",
"saveFailed": "記憶の保存に失敗しました",
"loadFailed": "記憶ファイルの読み込みに失敗しました",
"openConfig": "Hermes Web UI で上限を調整",
"runtimeNote": "変更は Hermes の再起動または新規セッション時に反映されます。"
}
},
"env": {
+13
View File
@@ -1675,6 +1675,19 @@
"parseFailed": "config.yaml 无法解析为有效 YAML。请先修复文件再在此编辑。",
"configNotFound": "未找到 Hermes config.yaml。请在 ~/.hermes/config.yaml 创建或在设置中配置路径。",
"envParseFailed": ".env 文件无法解析。"
},
"memory": {
"title": "记忆管理",
"agentTab": "Agent 记忆 (MEMORY.md)",
"userTab": "用户画像 (USER.md)",
"usage": "已用 {{current}} / {{limit}} 字符",
"overLimit": "已超过上限,Hermes 下次加载时会截断",
"disabled": "Memory 在 config.yaml 中已禁用,编辑后需在 Hermes 配置启用才会生效。",
"saveSuccess": "记忆已保存",
"saveFailed": "保存记忆失败",
"loadFailed": "读取记忆文件失败",
"openConfig": "在 Hermes Web UI 调整上限",
"runtimeNote": "更改将在 Hermes 下次启动或新建会话时生效。"
}
},
"env": {
+27 -1
View File
@@ -1,5 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import type { HermesModelConfig, HermesHealthWarning } from "@/types";
import type {
HermesHealthWarning,
HermesMemoryKind,
HermesMemoryLimits,
HermesModelConfig,
} from "@/types";
/**
* Hermes Agent configuration API (CC Switch side).
@@ -27,4 +32,25 @@ export const hermesApi = {
async openWebUI(path?: string): Promise<void> {
await invoke("open_hermes_web_ui", { path: path ?? null });
},
/**
* Read one of Hermes' memory blobs (`MEMORY.md` or `USER.md`). Returns an
* empty string when the file hasn't been created yet.
*/
async getMemory(kind: HermesMemoryKind): Promise<string> {
return await invoke("get_hermes_memory", { kind });
},
/** Atomically overwrite a Hermes memory file. */
async setMemory(kind: HermesMemoryKind, content: string): Promise<void> {
await invoke("set_hermes_memory", { kind, content });
},
/**
* Character budgets + enable flags for both memory blobs, read from
* config.yaml with Hermes defaults as fallback.
*/
async getMemoryLimits(): Promise<HermesMemoryLimits> {
return await invoke("get_hermes_memory_limits");
},
};
+9
View File
@@ -597,3 +597,12 @@ export interface HermesWriteOutcome {
backupPath?: string;
warnings: HermesHealthWarning[];
}
export type HermesMemoryKind = "memory" | "user";
export interface HermesMemoryLimits {
memory: number;
user: number;
memoryEnabled: boolean;
userEnabled: boolean;
}