diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index 95c27a322..25dd97d92 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -1,3 +1,6 @@ +use regex::Regex; +use std::sync::LazyLock; + use crate::config::write_text_file; use crate::openclaw_config::get_openclaw_dir; @@ -24,6 +27,146 @@ fn validate_filename(filename: &str) -> Result<(), String> { Ok(()) } +// --- Daily memory files (memory/YYYY-MM-DD.md) --- + +static DAILY_MEMORY_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap()); + +fn validate_daily_memory_filename(filename: &str) -> Result<(), String> { + if !DAILY_MEMORY_RE.is_match(filename) { + return Err(format!( + "Invalid daily memory filename: {filename}. Expected: YYYY-MM-DD.md" + )); + } + Ok(()) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyMemoryFileInfo { + pub filename: String, + pub date: String, + pub size_bytes: u64, + pub modified_at: u64, + pub preview: String, +} + +// --- Daily memory commands --- + +/// List all daily memory files under `workspace/memory/`. +#[tauri::command] +pub async fn list_daily_memory_files() -> Result, String> { + let memory_dir = get_openclaw_dir().join("workspace").join("memory"); + + if !memory_dir.exists() { + return Ok(Vec::new()); + } + + let mut files: Vec = Vec::new(); + + let entries = std::fs::read_dir(&memory_dir) + .map_err(|e| format!("Failed to read memory directory: {e}"))?; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.ends_with(".md") { + continue; + } + + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.is_file() { + continue; + } + + let date = name.trim_end_matches(".md").to_string(); + + let size_bytes = meta.len(); + let modified_at = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let preview = std::fs::read_to_string(entry.path()) + .unwrap_or_default() + .chars() + .take(200) + .collect::(); + + files.push(DailyMemoryFileInfo { + filename: name, + date, + size_bytes, + modified_at, + preview, + }); + } + + // Sort by modified_at descending (newest first) + files.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); + + Ok(files) +} + +/// Read a daily memory file. +#[tauri::command] +pub async fn read_daily_memory_file(filename: String) -> Result, String> { + validate_daily_memory_filename(&filename)?; + + let path = get_openclaw_dir() + .join("workspace") + .join("memory") + .join(&filename); + + if !path.exists() { + return Ok(None); + } + + std::fs::read_to_string(&path) + .map(Some) + .map_err(|e| format!("Failed to read daily memory file {filename}: {e}")) +} + +/// Write a daily memory file (atomic write). +#[tauri::command] +pub async fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> { + validate_daily_memory_filename(&filename)?; + + let memory_dir = get_openclaw_dir().join("workspace").join("memory"); + + std::fs::create_dir_all(&memory_dir) + .map_err(|e| format!("Failed to create memory directory: {e}"))?; + + let path = memory_dir.join(&filename); + + write_text_file(&path, &content) + .map_err(|e| format!("Failed to write daily memory file {filename}: {e}")) +} + +/// Delete a daily memory file (idempotent). +#[tauri::command] +pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> { + validate_daily_memory_filename(&filename)?; + + let path = get_openclaw_dir() + .join("workspace") + .join("memory") + .join(&filename); + + if path.exists() { + std::fs::remove_file(&path) + .map_err(|e| format!("Failed to delete daily memory file {filename}: {e}"))?; + } + + Ok(()) +} + +// --- Workspace file commands --- + /// Read an OpenClaw workspace file content. /// Returns None if the file does not exist. #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c82c879f6..31f168c41 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1071,6 +1071,11 @@ pub fn run() { // Workspace files (OpenClaw) commands::read_workspace_file, commands::write_workspace_file, + // Daily memory files (OpenClaw workspace) + commands::list_daily_memory_files, + commands::read_daily_memory_file, + commands::write_daily_memory_file, + commands::delete_daily_memory_file, ]); let app = builder diff --git a/src/components/workspace/DailyMemoryPanel.tsx b/src/components/workspace/DailyMemoryPanel.tsx new file mode 100644 index 000000000..0cbe9e1ac --- /dev/null +++ b/src/components/workspace/DailyMemoryPanel.tsx @@ -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 = ({ + isOpen, + onClose, +}) => { + const { t } = useTranslation(); + + // List state + const [files, setFiles] = useState([]); + const [loadingList, setLoadingList] = useState(false); + + // Edit state + const [editingFile, setEditingFile] = useState(null); + const [content, setContent] = useState(""); + const [loadingContent, setLoadingContent] = useState(false); + const [saving, setSaving] = useState(false); + + // Delete state + const [deletingFile, setDeletingFile] = useState(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 ( + <> + + {saving ? t("common.saving") : t("common.save")} + + } + > + {loadingContent ? ( +
+ {t("prompts.loading")} +
+ ) : ( + + )} +
+ + setDeletingFile(null)} + /> + + ); + } + + // --- List mode --- + return ( + <> + +
+ {/* Header with path and create button */} +
+

+ ~/.openclaw/workspace/memory/ +

+ +
+ + {/* File list */} + {loadingList ? ( +
+ {t("prompts.loading")} +
+ ) : files.length === 0 ? ( +
+ +

{t("workspace.dailyMemory.empty")}

+
+ ) : ( +
+ {files.map((file) => ( + + ))} +
+ )} +
+
+ + setDeletingFile(null)} + /> + + ); +}; + +export default DailyMemoryPanel; diff --git a/src/components/workspace/WorkspaceFilesPanel.tsx b/src/components/workspace/WorkspaceFilesPanel.tsx index 2d25405af..4e36640cc 100644 --- a/src/components/workspace/WorkspaceFilesPanel.tsx +++ b/src/components/workspace/WorkspaceFilesPanel.tsx @@ -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(null); const [fileExists, setFileExists] = useState>({}); + const [showDailyMemory, setShowDailyMemory] = useState(false); const checkFileExistence = async () => { const results: Record = {}; @@ -117,11 +121,42 @@ const WorkspaceFilesPanel: React.FC = () => { })} + {/* Daily Memory section */} +
+

+ {t("workspace.dailyMemory.sectionTitle")} +

+ +
+ + + setShowDailyMemory(false)} + /> ); }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dff7b48d5..2d64779ee 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e02a5ea13..38dd36d9d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1bedd8b4e..64ff76125 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": { diff --git a/src/lib/api/workspace.ts b/src/lib/api/workspace.ts index 095913483..bc4e2f642 100644 --- a/src/lib/api/workspace.ts +++ b/src/lib/api/workspace.ts @@ -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 { return invoke("read_workspace_file", { filename }); @@ -8,4 +16,20 @@ export const workspaceApi = { async writeFile(filename: string, content: string): Promise { return invoke("write_workspace_file", { filename, content }); }, + + async listDailyMemoryFiles(): Promise { + return invoke("list_daily_memory_files"); + }, + + async readDailyMemoryFile(filename: string): Promise { + return invoke("read_daily_memory_file", { filename }); + }, + + async writeDailyMemoryFile(filename: string, content: string): Promise { + return invoke("write_daily_memory_file", { filename, content }); + }, + + async deleteDailyMemoryFile(filename: string): Promise { + return invoke("delete_daily_memory_file", { filename }); + }, };