feat(workspace): add daily memory file management for OpenClaw

Add browse, edit, create and delete support for daily memory files
(~/.openclaw/workspace/memory/YYYY-MM-DD.md) in the Workspace panel.
This commit is contained in:
Jason
2026-02-20 08:54:53 +08:00
parent d04c279890
commit d1bb4480db
8 changed files with 556 additions and 3 deletions
@@ -0,0 +1,304 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Calendar, Trash2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import MarkdownEditor from "@/components/MarkdownEditor";
import { workspaceApi, type DailyMemoryFileInfo } from "@/lib/api/workspace";
interface DailyMemoryPanelProps {
isOpen: boolean;
onClose: () => void;
}
function getTodayFilename(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}.md`;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
isOpen,
onClose,
}) => {
const { t } = useTranslation();
// List state
const [files, setFiles] = useState<DailyMemoryFileInfo[]>([]);
const [loadingList, setLoadingList] = useState(false);
// Edit state
const [editingFile, setEditingFile] = useState<string | null>(null);
const [content, setContent] = useState("");
const [loadingContent, setLoadingContent] = useState(false);
const [saving, setSaving] = useState(false);
// Delete state
const [deletingFile, setDeletingFile] = useState<string | null>(null);
// Dark mode
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
// Load file list
const loadFiles = useCallback(async () => {
setLoadingList(true);
try {
const list = await workspaceApi.listDailyMemoryFiles();
setFiles(list);
} catch (err) {
console.error("Failed to load daily memory files:", err);
toast.error(t("workspace.dailyMemory.loadFailed"));
} finally {
setLoadingList(false);
}
}, [t]);
useEffect(() => {
if (isOpen) {
void loadFiles();
}
}, [isOpen, loadFiles]);
// Open file for editing
const openFile = useCallback(
async (filename: string) => {
setLoadingContent(true);
setEditingFile(filename);
try {
const data = await workspaceApi.readDailyMemoryFile(filename);
setContent(data ?? "");
} catch (err) {
console.error("Failed to read daily memory file:", err);
toast.error(t("workspace.dailyMemory.loadFailed"));
setEditingFile(null);
} finally {
setLoadingContent(false);
}
},
[t],
);
// Create today's note
const handleCreateToday = useCallback(async () => {
const filename = getTodayFilename();
// Check if already exists in the list
const existing = files.find((f) => f.filename === filename);
if (existing) {
// Just open it
await openFile(filename);
return;
}
// Create with empty content, then open
try {
await workspaceApi.writeDailyMemoryFile(filename, "");
await loadFiles();
await openFile(filename);
} catch (err) {
console.error("Failed to create daily memory file:", err);
toast.error(t("workspace.dailyMemory.createFailed"));
}
}, [files, openFile, loadFiles, t]);
// Save current file
const handleSave = useCallback(async () => {
if (!editingFile) return;
setSaving(true);
try {
await workspaceApi.writeDailyMemoryFile(editingFile, content);
toast.success(t("workspace.saveSuccess"));
} catch (err) {
console.error("Failed to save daily memory file:", err);
toast.error(t("workspace.saveFailed"));
} finally {
setSaving(false);
}
}, [editingFile, content, t]);
// Delete file
const handleDelete = useCallback(async () => {
if (!deletingFile) return;
try {
await workspaceApi.deleteDailyMemoryFile(deletingFile);
toast.success(t("workspace.dailyMemory.deleteSuccess"));
setDeletingFile(null);
// If we were editing this file, go back to list
if (editingFile === deletingFile) {
setEditingFile(null);
}
await loadFiles();
} catch (err) {
console.error("Failed to delete daily memory file:", err);
toast.error(t("workspace.dailyMemory.deleteFailed"));
setDeletingFile(null);
}
}, [deletingFile, editingFile, loadFiles, t]);
// Back from edit mode to list mode
const handleBackToList = useCallback(() => {
setEditingFile(null);
setContent("");
void loadFiles();
}, [loadFiles]);
// Close panel entirely
const handleClose = useCallback(() => {
setEditingFile(null);
setContent("");
onClose();
}, [onClose]);
// --- Edit mode ---
if (editingFile) {
return (
<>
<FullScreenPanel
isOpen={isOpen}
title={t("workspace.editing", { filename: editingFile })}
onClose={handleBackToList}
footer={
<Button onClick={handleSave} disabled={saving || loadingContent}>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
{loadingContent ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
{t("prompts.loading")}
</div>
) : (
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={isDarkMode}
placeholder={`# ${editingFile}\n\n...`}
minHeight="calc(100vh - 240px)"
/>
)}
</FullScreenPanel>
<ConfirmDialog
isOpen={!!deletingFile}
title={t("workspace.dailyMemory.confirmDeleteTitle")}
message={t("workspace.dailyMemory.confirmDeleteMessage", {
date: deletingFile?.replace(".md", "") ?? "",
})}
onConfirm={handleDelete}
onCancel={() => setDeletingFile(null)}
/>
</>
);
}
// --- List mode ---
return (
<>
<FullScreenPanel
isOpen={isOpen}
title={t("workspace.dailyMemory.title")}
onClose={handleClose}
>
<div className="space-y-4">
{/* Header with path and create button */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
~/.openclaw/workspace/memory/
</p>
<Button
variant="outline"
size="sm"
onClick={handleCreateToday}
className="gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
{t("workspace.dailyMemory.createToday")}
</Button>
</div>
{/* File list */}
{loadingList ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{t("prompts.loading")}
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3">
<Calendar className="w-10 h-10 opacity-40" />
<p className="text-sm">{t("workspace.dailyMemory.empty")}</p>
</div>
) : (
<div className="space-y-2">
{files.map((file) => (
<button
key={file.filename}
onClick={() => openFile(file.filename)}
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<Calendar className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">
{file.date}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.sizeBytes)}
</span>
</div>
{file.preview && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{file.preview}
</p>
)}
</div>
<div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
setDeletingFile(file.filename);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
</div>
</button>
))}
</div>
)}
</div>
</FullScreenPanel>
<ConfirmDialog
isOpen={!!deletingFile}
title={t("workspace.dailyMemory.confirmDeleteTitle")}
message={t("workspace.dailyMemory.confirmDeleteMessage", {
date: deletingFile?.replace(".md", "") ?? "",
})}
onConfirm={handleDelete}
onCancel={() => setDeletingFile(null)}
/>
</>
);
};
export default DailyMemoryPanel;
@@ -12,10 +12,13 @@ import {
Power,
CheckCircle2,
Circle,
Calendar,
ChevronRight,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { workspaceApi } from "@/lib/api/workspace";
import WorkspaceFileEditor from "./WorkspaceFileEditor";
import DailyMemoryPanel from "./DailyMemoryPanel";
interface WorkspaceFile {
filename: string;
@@ -51,6 +54,7 @@ const WorkspaceFilesPanel: React.FC = () => {
const { t } = useTranslation();
const [editingFile, setEditingFile] = useState<string | null>(null);
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
const [showDailyMemory, setShowDailyMemory] = useState(false);
const checkFileExistence = async () => {
const results: Record<string, boolean> = {};
@@ -117,11 +121,42 @@ const WorkspaceFilesPanel: React.FC = () => {
})}
</div>
{/* Daily Memory section */}
<div className="mt-8">
<h3 className="text-sm font-medium text-foreground mb-3">
{t("workspace.dailyMemory.sectionTitle")}
</h3>
<button
onClick={() => setShowDailyMemory(true)}
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<Calendar className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<span className="font-medium text-sm text-foreground">
{t("workspace.dailyMemory.cardTitle")}
</span>
<p className="text-xs text-muted-foreground mt-0.5">
{t("workspace.dailyMemory.cardDescription")}
</p>
</div>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<ChevronRight className="w-4 h-4" />
</div>
</button>
</div>
<WorkspaceFileEditor
filename={editingFile ?? ""}
isOpen={!!editingFile}
onClose={handleEditorClose}
/>
<DailyMemoryPanel
isOpen={showDailyMemory}
onClose={() => setShowDailyMemory(false)}
/>
</div>
);
};
+15 -1
View File
@@ -1166,7 +1166,21 @@
"editing": "Edit {{filename}}",
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"loadFailed": "Failed to load"
"loadFailed": "Failed to load",
"dailyMemory": {
"title": "Daily Memory",
"sectionTitle": "Daily Memory",
"cardTitle": "Daily Memory Files",
"cardDescription": "Browse and manage daily memory notes (memory/YYYY-MM-DD.md)",
"createToday": "Today's Note",
"empty": "No daily memory files yet",
"loadFailed": "Failed to load daily memory files",
"createFailed": "Failed to create daily memory file",
"deleteSuccess": "Daily memory file deleted",
"deleteFailed": "Failed to delete daily memory file",
"confirmDeleteTitle": "Delete Daily Memory",
"confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone."
}
},
"openclaw": {
"env": {
+15 -1
View File
@@ -1166,7 +1166,21 @@
"editing": "{{filename}} を編集",
"saveSuccess": "保存しました",
"saveFailed": "保存に失敗しました",
"loadFailed": "読み込みに失敗しました"
"loadFailed": "読み込みに失敗しました",
"dailyMemory": {
"title": "デイリーメモリー",
"sectionTitle": "デイリーメモリー",
"cardTitle": "デイリーメモリーファイル",
"cardDescription": "デイリーメモリーノートの閲覧と管理(memory/YYYY-MM-DD.md",
"createToday": "今日のノート",
"empty": "デイリーメモリーファイルはまだありません",
"loadFailed": "デイリーメモリーファイルの読み込みに失敗しました",
"createFailed": "デイリーメモリーファイルの作成に失敗しました",
"deleteSuccess": "デイリーメモリーファイルを削除しました",
"deleteFailed": "デイリーメモリーファイルの削除に失敗しました",
"confirmDeleteTitle": "デイリーメモリーを削除",
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。"
}
},
"openclaw": {
"env": {
+15 -1
View File
@@ -1166,7 +1166,21 @@
"editing": "编辑 {{filename}}",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"loadFailed": "读取失败"
"loadFailed": "读取失败",
"dailyMemory": {
"title": "每日记忆",
"sectionTitle": "每日记忆",
"cardTitle": "每日记忆文件",
"cardDescription": "浏览和管理每日记忆笔记(memory/YYYY-MM-DD.md",
"createToday": "今日笔记",
"empty": "暂无每日记忆文件",
"loadFailed": "加载每日记忆文件失败",
"createFailed": "创建每日记忆文件失败",
"deleteSuccess": "每日记忆文件已删除",
"deleteFailed": "删除每日记忆文件失败",
"confirmDeleteTitle": "删除每日记忆",
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。"
}
},
"openclaw": {
"env": {
+24
View File
@@ -1,5 +1,13 @@
import { invoke } from "@tauri-apps/api/core";
export interface DailyMemoryFileInfo {
filename: string;
date: string;
sizeBytes: number;
modifiedAt: number;
preview: string;
}
export const workspaceApi = {
async readFile(filename: string): Promise<string | null> {
return invoke<string | null>("read_workspace_file", { filename });
@@ -8,4 +16,20 @@ export const workspaceApi = {
async writeFile(filename: string, content: string): Promise<void> {
return invoke<void>("write_workspace_file", { filename, content });
},
async listDailyMemoryFiles(): Promise<DailyMemoryFileInfo[]> {
return invoke<DailyMemoryFileInfo[]>("list_daily_memory_files");
},
async readDailyMemoryFile(filename: string): Promise<string | null> {
return invoke<string | null>("read_daily_memory_file", { filename });
},
async writeDailyMemoryFile(filename: string, content: string): Promise<void> {
return invoke<void>("write_daily_memory_file", { filename, content });
},
async deleteDailyMemoryFile(filename: string): Promise<void> {
return invoke<void>("delete_daily_memory_file", { filename });
},
};