mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-15 07:42:28 +08:00
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:
@@ -147,6 +147,141 @@ pub async fn write_daily_memory_file(filename: String, content: String) -> Resul
|
|||||||
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
|
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find the largest index `<= i` that is a valid UTF-8 char boundary.
|
||||||
|
/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).
|
||||||
|
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
|
||||||
|
if i >= s.len() {
|
||||||
|
return s.len();
|
||||||
|
}
|
||||||
|
while !s.is_char_boundary(i) {
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.
|
||||||
|
/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).
|
||||||
|
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
|
||||||
|
if i >= s.len() {
|
||||||
|
return s.len();
|
||||||
|
}
|
||||||
|
while !s.is_char_boundary(i) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search result for daily memory full-text search.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DailyMemorySearchResult {
|
||||||
|
pub filename: String,
|
||||||
|
pub date: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub modified_at: u64,
|
||||||
|
pub snippet: String,
|
||||||
|
pub match_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-text search across all daily memory files.
|
||||||
|
///
|
||||||
|
/// Performs case-insensitive search on both the date field and file content.
|
||||||
|
/// Returns results sorted by filename descending (newest first), each with a
|
||||||
|
/// snippet showing ~120 characters of context around the first match.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_daily_memory_files(
|
||||||
|
query: String,
|
||||||
|
) -> Result<Vec<DailyMemorySearchResult>, String> {
|
||||||
|
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||||
|
|
||||||
|
if !memory_dir.exists() || query.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let mut results: Vec<DailyMemorySearchResult> = 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) if m.is_file() => m,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let date = name.trim_end_matches(".md").to_string();
|
||||||
|
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
|
||||||
|
let content_lower = content.to_lowercase();
|
||||||
|
|
||||||
|
// Count matches in content
|
||||||
|
let content_matches: Vec<usize> = content_lower
|
||||||
|
.match_indices(&query_lower)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Also check date field
|
||||||
|
let date_matches = date.to_lowercase().contains(&query_lower);
|
||||||
|
|
||||||
|
if content_matches.is_empty() && !date_matches {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build snippet around first content match (~120 chars of context)
|
||||||
|
let snippet = if let Some(&first_pos) = content_matches.first() {
|
||||||
|
let start = if first_pos > 50 {
|
||||||
|
floor_char_boundary(&content, first_pos - 50)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));
|
||||||
|
let mut s = String::new();
|
||||||
|
if start > 0 {
|
||||||
|
s.push_str("...");
|
||||||
|
}
|
||||||
|
s.push_str(&content[start..end]);
|
||||||
|
if end < content.len() {
|
||||||
|
s.push_str("...");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
// Date-only match — use beginning of file as preview
|
||||||
|
let end = ceil_char_boundary(&content, 120.min(content.len()));
|
||||||
|
let mut s = content[..end].to_string();
|
||||||
|
if end < content.len() {
|
||||||
|
s.push_str("...");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
results.push(DailyMemorySearchResult {
|
||||||
|
filename: name,
|
||||||
|
date,
|
||||||
|
size_bytes,
|
||||||
|
modified_at,
|
||||||
|
snippet,
|
||||||
|
match_count: content_matches.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by filename descending (newest date first)
|
||||||
|
results.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a daily memory file (idempotent).
|
/// Delete a daily memory file (idempotent).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
|
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
|
||||||
|
|||||||
@@ -1051,6 +1051,7 @@ pub fn run() {
|
|||||||
commands::read_daily_memory_file,
|
commands::read_daily_memory_file,
|
||||||
commands::write_daily_memory_file,
|
commands::write_daily_memory_file,
|
||||||
commands::delete_daily_memory_file,
|
commands::delete_daily_memory_file,
|
||||||
|
commands::search_daily_memory_files,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
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 {
|
interface DailyMemoryPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -46,6 +58,16 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
|||||||
// Delete state
|
// Delete state
|
||||||
const [deletingFile, setDeletingFile] = useState<string | null>(null);
|
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
|
// Dark mode
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
|
||||||
@@ -61,6 +83,100 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
|||||||
return () => observer.disconnect();
|
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
|
// Load file list
|
||||||
const loadFiles = useCallback(async () => {
|
const loadFiles = useCallback(async () => {
|
||||||
setLoadingList(true);
|
setLoadingList(true);
|
||||||
@@ -142,24 +258,44 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
|||||||
setEditingFile(null);
|
setEditingFile(null);
|
||||||
}
|
}
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
|
// Re-trigger search if active
|
||||||
|
if (isSearchOpen && searchTerm.trim()) {
|
||||||
|
void executeSearch(searchTerm);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete daily memory file:", err);
|
console.error("Failed to delete daily memory file:", err);
|
||||||
toast.error(t("workspace.dailyMemory.deleteFailed"));
|
toast.error(t("workspace.dailyMemory.deleteFailed"));
|
||||||
setDeletingFile(null);
|
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(() => {
|
const handleBackToList = useCallback(() => {
|
||||||
setEditingFile(null);
|
setEditingFile(null);
|
||||||
setContent("");
|
setContent("");
|
||||||
void loadFiles();
|
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(() => {
|
const handleClose = useCallback(() => {
|
||||||
setEditingFile(null);
|
setEditingFile(null);
|
||||||
setContent("");
|
setContent("");
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearching(false);
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
@@ -214,24 +350,137 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header with path and create button */}
|
{/* Header with path, search, and create button */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground shrink-0">
|
||||||
~/.openclaw/workspace/memory/
|
~/.openclaw/workspace/memory/
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<div className="flex items-center gap-1.5">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={handleCreateToday}
|
size="icon"
|
||||||
className="gap-1.5"
|
className="h-8 w-8"
|
||||||
>
|
onClick={isSearchOpen ? closeSearch : openSearch}
|
||||||
<Plus className="w-3.5 h-3.5" />
|
title={t("workspace.dailyMemory.searchScopeHint")}
|
||||||
{t("workspace.dailyMemory.createToday")}
|
>
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* File list */}
|
{/* Search bar */}
|
||||||
{loadingList ? (
|
<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">
|
<div className="flex items-center justify-center h-48 text-muted-foreground">
|
||||||
{t("prompts.loading")}
|
{t("prompts.loading")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1205,7 +1205,14 @@
|
|||||||
"deleteSuccess": "Daily memory file deleted",
|
"deleteSuccess": "Daily memory file deleted",
|
||||||
"deleteFailed": "Failed to delete daily memory file",
|
"deleteFailed": "Failed to delete daily memory file",
|
||||||
"confirmDeleteTitle": "Delete Daily Memory",
|
"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": {
|
"openclaw": {
|
||||||
|
|||||||
@@ -1205,7 +1205,14 @@
|
|||||||
"deleteSuccess": "デイリーメモリーファイルを削除しました",
|
"deleteSuccess": "デイリーメモリーファイルを削除しました",
|
||||||
"deleteFailed": "デイリーメモリーファイルの削除に失敗しました",
|
"deleteFailed": "デイリーメモリーファイルの削除に失敗しました",
|
||||||
"confirmDeleteTitle": "デイリーメモリーを削除",
|
"confirmDeleteTitle": "デイリーメモリーを削除",
|
||||||
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。"
|
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。",
|
||||||
|
"searchPlaceholder": "全文検索...",
|
||||||
|
"searchScopeHint": "すべてのデイリーメモリーを全文検索 ⌘F",
|
||||||
|
"searchCloseHint": "Escで閉じる",
|
||||||
|
"noSearchResults": "検索に一致するデイリーメモリーはありません。",
|
||||||
|
"searching": "検索中...",
|
||||||
|
"searchFailed": "検索に失敗しました",
|
||||||
|
"matchCount": "{{count}}件一致"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"openclaw": {
|
"openclaw": {
|
||||||
|
|||||||
@@ -1205,7 +1205,14 @@
|
|||||||
"deleteSuccess": "每日记忆文件已删除",
|
"deleteSuccess": "每日记忆文件已删除",
|
||||||
"deleteFailed": "删除每日记忆文件失败",
|
"deleteFailed": "删除每日记忆文件失败",
|
||||||
"confirmDeleteTitle": "删除每日记忆",
|
"confirmDeleteTitle": "删除每日记忆",
|
||||||
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。"
|
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。",
|
||||||
|
"searchPlaceholder": "搜索全文内容...",
|
||||||
|
"searchScopeHint": "全文搜索所有每日记忆 ⌘F",
|
||||||
|
"searchCloseHint": "Esc 关闭",
|
||||||
|
"noSearchResults": "没有找到匹配的每日记忆。",
|
||||||
|
"searching": "搜索中...",
|
||||||
|
"searchFailed": "搜索失败",
|
||||||
|
"matchCount": "{{count}} 处匹配"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"openclaw": {
|
"openclaw": {
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export interface DailyMemoryFileInfo {
|
|||||||
preview: string;
|
preview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyMemorySearchResult {
|
||||||
|
filename: string;
|
||||||
|
date: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
modifiedAt: number;
|
||||||
|
snippet: string;
|
||||||
|
matchCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const workspaceApi = {
|
export const workspaceApi = {
|
||||||
async readFile(filename: string): Promise<string | null> {
|
async readFile(filename: string): Promise<string | null> {
|
||||||
return invoke<string | null>("read_workspace_file", { filename });
|
return invoke<string | null>("read_workspace_file", { filename });
|
||||||
@@ -32,4 +41,12 @@ export const workspaceApi = {
|
|||||||
async deleteDailyMemoryFile(filename: string): Promise<void> {
|
async deleteDailyMemoryFile(filename: string): Promise<void> {
|
||||||
return invoke<void>("delete_daily_memory_file", { filename });
|
return invoke<void>("delete_daily_memory_file", { filename });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async searchDailyMemoryFiles(
|
||||||
|
query: string,
|
||||||
|
): Promise<DailyMemorySearchResult[]> {
|
||||||
|
return invoke<DailyMemorySearchResult[]>("search_daily_memory_files", {
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user