mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-22 21:50:44 +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:
@@ -57,6 +57,25 @@ pub fn get_hermes_model_config() -> Result<Option<hermes_config::HermesModelConf
|
||||
hermes_config::get_model_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory Files Commands
|
||||
// ============================================================================
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_memory(kind: hermes_config::MemoryKind) -> Result<String, String> {
|
||||
hermes_config::read_memory(kind).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_hermes_memory(kind: hermes_config::MemoryKind, content: String) -> Result<(), String> {
|
||||
hermes_config::write_memory(kind, &content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_memory_limits() -> Result<hermes_config::HermesMemoryLimits, String> {
|
||||
hermes_config::read_memory_limits().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hermes Web UI launcher
|
||||
// ============================================================================
|
||||
|
||||
@@ -763,6 +763,111 @@ pub(crate) fn json_to_yaml(json: &serde_json::Value) -> Result<serde_yaml::Value
|
||||
.map_err(|e| AppError::Config(format!("Failed to convert JSON to YAML: {e}")))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory Files (~/.hermes/memories/{MEMORY,USER}.md)
|
||||
// ============================================================================
|
||||
//
|
||||
// Hermes Agent persists two memory blobs on disk:
|
||||
// - `MEMORY.md` — agent's personal notes, snapshotted into the system prompt
|
||||
// - `USER.md` — user profile, same treatment
|
||||
// Entries are separated by a `§` on its own line. Hermes' own Web UI only
|
||||
// exposes on/off toggles and character budgets — it has no content editor.
|
||||
// CC Switch fills that gap by reading/writing the whole file as a markdown
|
||||
// blob. Character budgets (`memory_char_limit`, `user_char_limit`) and enable
|
||||
// flags (`memory_enabled`, `user_profile_enabled`) live at the top level of
|
||||
// `config.yaml`; Hermes truncates over-budget content at load time.
|
||||
|
||||
/// Which of Hermes' two memory files to operate on. Tauri deserializes this
|
||||
/// directly from the `"memory"` / `"user"` strings the frontend sends, so an
|
||||
/// unknown value is rejected at the IPC boundary instead of deep in the stack.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MemoryKind {
|
||||
Memory,
|
||||
User,
|
||||
}
|
||||
|
||||
impl MemoryKind {
|
||||
fn filename(self) -> &'static str {
|
||||
match self {
|
||||
Self::Memory => "MEMORY.md",
|
||||
Self::User => "USER.md",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn memories_dir() -> PathBuf {
|
||||
get_hermes_dir().join("memories")
|
||||
}
|
||||
|
||||
/// Read a Hermes memory file as a markdown blob. Returns an empty string
|
||||
/// when the file doesn't exist yet (first-run case).
|
||||
pub fn read_memory(kind: MemoryKind) -> Result<String, AppError> {
|
||||
let path = memories_dir().join(kind.filename());
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => Ok(content),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
|
||||
Err(e) => Err(AppError::io(&path, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically replace a Hermes memory file. `atomic_write` creates parent
|
||||
/// directories as needed, so `~/.hermes/memories/` is materialized on first
|
||||
/// write without a separate `create_dir_all` call.
|
||||
pub fn write_memory(kind: MemoryKind, content: &str) -> Result<(), AppError> {
|
||||
let path = memories_dir().join(kind.filename());
|
||||
atomic_write(&path, content.as_bytes())
|
||||
}
|
||||
|
||||
/// Character budget + enable flags for the two memory blobs, as configured
|
||||
/// in Hermes' `config.yaml`. Defaults mirror `~/.hermes`'s own defaults so
|
||||
/// callers get a usable budget bar even before the user edits config.yaml.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HermesMemoryLimits {
|
||||
pub memory: usize,
|
||||
pub user: usize,
|
||||
pub memory_enabled: bool,
|
||||
pub user_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for HermesMemoryLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
memory: 2200,
|
||||
user: 1375,
|
||||
memory_enabled: true,
|
||||
user_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read memory budgets + toggles from `config.yaml`. Missing/unparsable
|
||||
/// fields fall back to `HermesMemoryLimits::default()` rather than erroring,
|
||||
/// so an empty or partially-populated config still yields a usable UI.
|
||||
pub fn read_memory_limits() -> Result<HermesMemoryLimits, AppError> {
|
||||
let mut out = HermesMemoryLimits::default();
|
||||
let config = read_hermes_config()?;
|
||||
let Some(memory) = config.get("memory") else {
|
||||
return Ok(out);
|
||||
};
|
||||
|
||||
if let Some(v) = memory.get("memory_char_limit").and_then(|v| v.as_u64()) {
|
||||
out.memory = v as usize;
|
||||
}
|
||||
if let Some(v) = memory.get("user_char_limit").and_then(|v| v.as_u64()) {
|
||||
out.user = v as usize;
|
||||
}
|
||||
if let Some(v) = memory.get("memory_enabled").and_then(|v| v.as_bool()) {
|
||||
out.memory_enabled = v;
|
||||
}
|
||||
if let Some(v) = memory.get("user_profile_enabled").and_then(|v| v.as_bool()) {
|
||||
out.user_enabled = v;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
@@ -1349,4 +1454,103 @@ custom_providers:
|
||||
assert_eq!(model.default.as_deref(), Some("prev-default"));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- memory file tests ----
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn read_memory_returns_empty_when_file_missing() {
|
||||
with_test_home(|| {
|
||||
let memory = read_memory(MemoryKind::Memory).unwrap();
|
||||
let user = read_memory(MemoryKind::User).unwrap();
|
||||
assert!(memory.is_empty());
|
||||
assert!(user.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn write_then_read_memory_round_trip() {
|
||||
with_test_home(|| {
|
||||
let blob = "> note\n§\nfirst entry\n§\nsecond entry\n";
|
||||
write_memory(MemoryKind::Memory, blob).unwrap();
|
||||
assert_eq!(read_memory(MemoryKind::Memory).unwrap(), blob);
|
||||
|
||||
// Writing USER.md doesn't clobber MEMORY.md.
|
||||
write_memory(MemoryKind::User, "user profile").unwrap();
|
||||
assert_eq!(read_memory(MemoryKind::Memory).unwrap(), blob);
|
||||
assert_eq!(read_memory(MemoryKind::User).unwrap(), "user profile");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn memory_limits_fall_back_to_defaults_when_config_missing() {
|
||||
with_test_home(|| {
|
||||
let limits = read_memory_limits().unwrap();
|
||||
let defaults = HermesMemoryLimits::default();
|
||||
assert_eq!(limits.memory, defaults.memory);
|
||||
assert_eq!(limits.user, defaults.user);
|
||||
assert_eq!(limits.memory_enabled, defaults.memory_enabled);
|
||||
assert_eq!(limits.user_enabled, defaults.user_enabled);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn memory_limits_read_from_config_yaml() {
|
||||
with_test_home(|| {
|
||||
let yaml = "\
|
||||
memory:
|
||||
memory_char_limit: 4096
|
||||
user_char_limit: 2048
|
||||
memory_enabled: false
|
||||
user_profile_enabled: true
|
||||
";
|
||||
let config_path = get_hermes_config_path();
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
fs::write(&config_path, yaml).unwrap();
|
||||
|
||||
let limits = read_memory_limits().unwrap();
|
||||
assert_eq!(limits.memory, 4096);
|
||||
assert_eq!(limits.user, 2048);
|
||||
assert!(!limits.memory_enabled);
|
||||
assert!(limits.user_enabled);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn memory_limits_ignore_top_level_keys() {
|
||||
// Regression guard: Hermes nests memory settings under `memory:`, so
|
||||
// identically-named keys at the top level must be ignored rather than
|
||||
// silently consumed.
|
||||
with_test_home(|| {
|
||||
let yaml = "\
|
||||
memory_char_limit: 9999
|
||||
user_char_limit: 9999
|
||||
memory_enabled: false
|
||||
user_profile_enabled: false
|
||||
";
|
||||
let config_path = get_hermes_config_path();
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
fs::write(&config_path, yaml).unwrap();
|
||||
|
||||
let limits = read_memory_limits().unwrap();
|
||||
let defaults = HermesMemoryLimits::default();
|
||||
assert_eq!(limits.memory, defaults.memory);
|
||||
assert_eq!(limits.user, defaults.user);
|
||||
assert_eq!(limits.memory_enabled, defaults.memory_enabled);
|
||||
assert_eq!(limits.user_enabled, defaults.user_enabled);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_kind_deserializes_from_lowercase_strings() {
|
||||
let memory: MemoryKind = serde_json::from_str("\"memory\"").unwrap();
|
||||
let user: MemoryKind = serde_json::from_str("\"user\"").unwrap();
|
||||
assert_eq!(memory, MemoryKind::Memory);
|
||||
assert_eq!(user, MemoryKind::User);
|
||||
assert!(serde_json::from_str::<MemoryKind>("\"bogus\"").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,9 @@ pub fn run() {
|
||||
commands::scan_hermes_config_health,
|
||||
commands::get_hermes_model_config,
|
||||
commands::open_hermes_web_ui,
|
||||
commands::get_hermes_memory,
|
||||
commands::set_hermes_memory,
|
||||
commands::get_hermes_memory_limits,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
|
||||
+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