feat(workspace): add full-text search for daily memory files

Add backend search command that performs case-insensitive matching
across all daily memory files, supporting both date and content queries.
Frontend includes animated search bar (⌘F), debounced input, snippet
display with match count badge, and search state preservation across
edits.
This commit is contained in:
Jason
2026-02-24 09:03:30 +08:00
parent 7138aca310
commit a8dbea1398
7 changed files with 447 additions and 24 deletions
+270 -21
View File
@@ -1,12 +1,24 @@
import React, { useState, useEffect, useCallback } from "react";
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Calendar, Trash2, Plus } from "lucide-react";
import { Calendar, Trash2, Plus, Search, X } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import MarkdownEditor from "@/components/MarkdownEditor";
import { workspaceApi, type DailyMemoryFileInfo } from "@/lib/api/workspace";
import {
workspaceApi,
type DailyMemoryFileInfo,
type DailyMemorySearchResult,
} from "@/lib/api/workspace";
interface DailyMemoryPanelProps {
isOpen: boolean;
@@ -46,6 +58,16 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
// Delete state
const [deletingFile, setDeletingFile] = useState<string | null>(null);
// Search state
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchResults, setSearchResults] = useState<DailyMemorySearchResult[]>(
[],
);
const [searching, setSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Dark mode
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -61,6 +83,100 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
return () => observer.disconnect();
}, []);
// Whether we are in active search mode (search open with a non-empty term)
const isActiveSearch = useMemo(
() => isSearchOpen && searchTerm.trim().length > 0,
[isSearchOpen, searchTerm],
);
// Debounced search execution
const executeSearch = useCallback(
async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
setSearching(false);
return;
}
setSearching(true);
try {
const results = await workspaceApi.searchDailyMemoryFiles(query.trim());
setSearchResults(results);
} catch (err) {
console.error("Failed to search daily memory files:", err);
toast.error(t("workspace.dailyMemory.searchFailed"));
} finally {
setSearching(false);
}
},
[t],
);
// Handle search input change with debounce
const handleSearchChange = useCallback(
(value: string) => {
setSearchTerm(value);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
void executeSearch(value);
}, 300);
},
[executeSearch],
);
// Open search bar
const openSearch = useCallback(() => {
setIsSearchOpen(true);
// Focus input on next frame
requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
}, []);
// Close search bar and clear state
const closeSearch = useCallback(() => {
setIsSearchOpen(false);
setSearchTerm("");
setSearchResults([]);
setSearching(false);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
}, []);
// Keyboard shortcut: Cmd/Ctrl+F to open search, Escape to close
useEffect(() => {
if (!isOpen || editingFile) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "f") {
e.preventDefault();
if (!isSearchOpen) {
openSearch();
} else {
searchInputRef.current?.focus();
}
}
if (e.key === "Escape" && isSearchOpen) {
e.preventDefault();
closeSearch();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, editingFile, isSearchOpen, openSearch, closeSearch]);
// Clean up debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Load file list
const loadFiles = useCallback(async () => {
setLoadingList(true);
@@ -142,24 +258,44 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
setEditingFile(null);
}
await loadFiles();
// Re-trigger search if active
if (isSearchOpen && searchTerm.trim()) {
void executeSearch(searchTerm);
}
} catch (err) {
console.error("Failed to delete daily memory file:", err);
toast.error(t("workspace.dailyMemory.deleteFailed"));
setDeletingFile(null);
}
}, [deletingFile, editingFile, loadFiles, t]);
}, [
deletingFile,
editingFile,
loadFiles,
t,
isSearchOpen,
searchTerm,
executeSearch,
]);
// Back from edit mode to list mode
// Back from edit mode to list mode — preserve search state
const handleBackToList = useCallback(() => {
setEditingFile(null);
setContent("");
void loadFiles();
}, [loadFiles]);
// Re-trigger search if active (file content may have changed)
if (isSearchOpen && searchTerm.trim()) {
void executeSearch(searchTerm);
}
}, [loadFiles, isSearchOpen, searchTerm, executeSearch]);
// Close panel entirely
// Close panel entirely — clear search state
const handleClose = useCallback(() => {
setEditingFile(null);
setContent("");
setIsSearchOpen(false);
setSearchTerm("");
setSearchResults([]);
setSearching(false);
onClose();
}, [onClose]);
@@ -214,24 +350,137 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
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">
{/* Header with path, search, and create button */}
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground shrink-0">
~/.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 className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={isSearchOpen ? closeSearch : openSearch}
title={t("workspace.dailyMemory.searchScopeHint")}
>
<Search className="w-4 h-4" />
</Button>
<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>
</div>
{/* File list */}
{loadingList ? (
{/* Search bar */}
<AnimatePresence>
{isSearchOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder={t("workspace.dailyMemory.searchPlaceholder")}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchTerm && (
<button
onClick={() => handleSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={closeSearch}
className="text-xs text-muted-foreground h-8 px-2 shrink-0"
>
{t("workspace.dailyMemory.searchCloseHint")}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Content: search results or normal file list */}
{isActiveSearch ? (
// --- Search results ---
searching ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{t("workspace.dailyMemory.searching")}
</div>
) : searchResults.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3 border-2 border-dashed border-border rounded-xl">
<Search className="w-10 h-10 opacity-40" />
<p className="text-sm">
{t("workspace.dailyMemory.noSearchResults")}
</p>
</div>
) : (
<div className="space-y-2">
{searchResults.map((result) => (
<button
key={result.filename}
onClick={() => openFile(result.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">
{result.date}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(result.sizeBytes)}
</span>
{result.matchCount > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{t("workspace.dailyMemory.matchCount", {
count: result.matchCount,
})}
</span>
)}
</div>
{result.snippet && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 whitespace-pre-line">
{result.snippet}
</p>
)}
</div>
<div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
setDeletingFile(result.filename);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
</div>
</button>
))}
</div>
)
) : // --- Normal file list ---
loadingList ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{t("prompts.loading")}
</div>
+8 -1
View File
@@ -1205,7 +1205,14 @@
"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."
"confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone.",
"searchPlaceholder": "Search full content...",
"searchScopeHint": "Full-text search across all daily memories. ⌘F",
"searchCloseHint": "Esc to close",
"noSearchResults": "No daily memories match your search.",
"searching": "Searching...",
"searchFailed": "Search failed",
"matchCount": "{{count}} match(es)"
}
},
"openclaw": {
+8 -1
View File
@@ -1205,7 +1205,14 @@
"deleteSuccess": "デイリーメモリーファイルを削除しました",
"deleteFailed": "デイリーメモリーファイルの削除に失敗しました",
"confirmDeleteTitle": "デイリーメモリーを削除",
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。"
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。",
"searchPlaceholder": "全文検索...",
"searchScopeHint": "すべてのデイリーメモリーを全文検索 ⌘F",
"searchCloseHint": "Escで閉じる",
"noSearchResults": "検索に一致するデイリーメモリーはありません。",
"searching": "検索中...",
"searchFailed": "検索に失敗しました",
"matchCount": "{{count}}件一致"
}
},
"openclaw": {
+8 -1
View File
@@ -1205,7 +1205,14 @@
"deleteSuccess": "每日记忆文件已删除",
"deleteFailed": "删除每日记忆文件失败",
"confirmDeleteTitle": "删除每日记忆",
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。"
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。",
"searchPlaceholder": "搜索全文内容...",
"searchScopeHint": "全文搜索所有每日记忆 ⌘F",
"searchCloseHint": "Esc 关闭",
"noSearchResults": "没有找到匹配的每日记忆。",
"searching": "搜索中...",
"searchFailed": "搜索失败",
"matchCount": "{{count}} 处匹配"
}
},
"openclaw": {
+17
View File
@@ -8,6 +8,15 @@ export interface DailyMemoryFileInfo {
preview: string;
}
export interface DailyMemorySearchResult {
filename: string;
date: string;
sizeBytes: number;
modifiedAt: number;
snippet: string;
matchCount: number;
}
export const workspaceApi = {
async readFile(filename: string): Promise<string | null> {
return invoke<string | null>("read_workspace_file", { filename });
@@ -32,4 +41,12 @@ export const workspaceApi = {
async deleteDailyMemoryFile(filename: string): Promise<void> {
return invoke<void>("delete_daily_memory_file", { filename });
},
async searchDailyMemoryFiles(
query: string,
): Promise<DailyMemorySearchResult[]> {
return invoke<DailyMemorySearchResult[]>("search_daily_memory_files", {
query,
});
},
};