mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
+11
-4
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user