Files
cc-switch/src/components/hermes/HermesMemoryPanel.tsx
T
Jason acc6d795e4 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.
2026-04-21 11:57:07 +08:00

156 lines
4.8 KiB
TypeScript

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;