Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- S9: MessageSearch 新增 Session/Global 双模式,Global 调用 VikingStorage memory_search - M4b: LLM 压缩器集成到 kernel AgentLoop,支持 use_llm 配置切换 - M4c: 压缩时自动提取记忆到 VikingStorage (runtime + tauri 双路径) - H6: 新增 ChartRenderer(recharts)、Document/Slideshow 完整渲染 - 累计修复 23 项,整体完成度 ~72%,真实可用率 ~80%
592 lines
22 KiB
TypeScript
592 lines
22 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Search, X, ChevronUp, ChevronDown, Clock, User, Filter, Globe, MessageSquare } from 'lucide-react';
|
|
import { Button } from './ui';
|
|
import { useChatStore, Message } from '../store/chatStore';
|
|
import { intelligence, PersistentMemory } from '../lib/intelligence-backend';
|
|
|
|
export interface SearchFilters {
|
|
sender: 'all' | 'user' | 'assistant';
|
|
timeRange: 'all' | 'today' | 'week' | 'month';
|
|
}
|
|
|
|
export type SearchScope = 'session' | 'global';
|
|
|
|
export interface SearchResult {
|
|
message: Message;
|
|
matchIndices: Array<{ start: number; end: number }>;
|
|
}
|
|
|
|
export interface GlobalSearchResult {
|
|
memory: PersistentMemory;
|
|
}
|
|
|
|
interface MessageSearchProps {
|
|
onNavigateToMessage: (messageId: string) => void;
|
|
}
|
|
|
|
const SEARCH_HISTORY_KEY = 'zclaw-search-history';
|
|
const MAX_HISTORY_ITEMS = 10;
|
|
|
|
export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
|
const { messages } = useChatStore();
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const [scope, setScope] = useState<SearchScope>('session');
|
|
const [filters, setFilters] = useState<SearchFilters>({
|
|
sender: 'all',
|
|
timeRange: 'all',
|
|
});
|
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
|
const [globalResults, setGlobalResults] = useState<GlobalSearchResult[]>([]);
|
|
const [globalLoading, setGlobalLoading] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Load search history from localStorage
|
|
useEffect(() => {
|
|
try {
|
|
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
|
|
if (saved) {
|
|
setSearchHistory(JSON.parse(saved));
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
}, []);
|
|
|
|
// Save search query to history
|
|
const saveToHistory = useCallback((searchQuery: string) => {
|
|
if (!searchQuery.trim()) return;
|
|
|
|
setSearchHistory((prev) => {
|
|
const filtered = prev.filter((item) => item !== searchQuery);
|
|
const updated = [searchQuery, ...filtered].slice(0, MAX_HISTORY_ITEMS);
|
|
try {
|
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
|
|
} catch {
|
|
// Ignore storage errors
|
|
}
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
// Global search: query VikingStorage when scope is 'global'
|
|
useEffect(() => {
|
|
if (scope !== 'global' || !query.trim()) {
|
|
setGlobalResults([]);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const debounceTimer = setTimeout(async () => {
|
|
setGlobalLoading(true);
|
|
try {
|
|
const results = await intelligence.memory.search({
|
|
query: query.trim(),
|
|
limit: 20,
|
|
});
|
|
if (!cancelled) {
|
|
setGlobalResults(results.map((memory) => ({ memory })));
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setGlobalResults([]);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setGlobalLoading(false);
|
|
}
|
|
}
|
|
}, 300);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(debounceTimer);
|
|
};
|
|
}, [scope, query]);
|
|
|
|
// Filter messages by time range
|
|
const filterByTimeRange = useCallback((message: Message, timeRange: SearchFilters['timeRange']): boolean => {
|
|
if (timeRange === 'all') return true;
|
|
|
|
const messageTime = new Date(message.timestamp).getTime();
|
|
const now = Date.now();
|
|
const day = 24 * 60 * 60 * 1000;
|
|
|
|
switch (timeRange) {
|
|
case 'today':
|
|
return messageTime >= now - day;
|
|
case 'week':
|
|
return messageTime >= now - 7 * day;
|
|
case 'month':
|
|
return messageTime >= now - 30 * day;
|
|
default:
|
|
return true;
|
|
}
|
|
}, []);
|
|
|
|
// Filter messages by sender
|
|
const filterBySender = useCallback((message: Message, sender: SearchFilters['sender']): boolean => {
|
|
if (sender === 'all') return true;
|
|
if (sender === 'user') return message.role === 'user';
|
|
if (sender === 'assistant') return message.role === 'assistant' || message.role === 'tool';
|
|
return true;
|
|
}, []);
|
|
|
|
// Search messages and find matches
|
|
const searchResults = useMemo((): SearchResult[] => {
|
|
if (!query.trim()) return [];
|
|
|
|
const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
if (searchTerms.length === 0) return [];
|
|
|
|
const results: SearchResult[] = [];
|
|
|
|
for (const message of messages) {
|
|
// Apply filters
|
|
if (!filterBySender(message, filters.sender)) continue;
|
|
if (!filterByTimeRange(message, filters.timeRange)) continue;
|
|
|
|
const content = message.content.toLowerCase();
|
|
const matchIndices: Array<{ start: number; end: number }> = [];
|
|
|
|
// Find all matches
|
|
for (const term of searchTerms) {
|
|
let startIndex = 0;
|
|
while (true) {
|
|
const index = content.indexOf(term, startIndex);
|
|
if (index === -1) break;
|
|
matchIndices.push({ start: index, end: index + term.length });
|
|
startIndex = index + 1;
|
|
}
|
|
}
|
|
|
|
if (matchIndices.length > 0) {
|
|
// Sort and merge overlapping matches
|
|
matchIndices.sort((a, b) => a.start - b.start);
|
|
const merged: Array<{ start: number; end: number }> = [];
|
|
for (const match of matchIndices) {
|
|
if (merged.length === 0 || merged[merged.length - 1].end < match.start) {
|
|
merged.push(match);
|
|
} else {
|
|
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, match.end);
|
|
}
|
|
}
|
|
results.push({ message, matchIndices: merged });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}, [query, messages, filters, filterBySender, filterByTimeRange]);
|
|
|
|
// Navigate to previous match
|
|
const handlePrevious = useCallback(() => {
|
|
if (searchResults.length === 0) return;
|
|
setCurrentMatchIndex((prev) =>
|
|
prev > 0 ? prev - 1 : searchResults.length - 1
|
|
);
|
|
const result = searchResults[currentMatchIndex > 0 ? currentMatchIndex - 1 : searchResults.length - 1];
|
|
onNavigateToMessage(result.message.id);
|
|
}, [searchResults, currentMatchIndex, onNavigateToMessage]);
|
|
|
|
// Navigate to next match
|
|
const handleNext = useCallback(() => {
|
|
if (searchResults.length === 0) return;
|
|
setCurrentMatchIndex((prev) =>
|
|
prev < searchResults.length - 1 ? prev + 1 : 0
|
|
);
|
|
const result = searchResults[currentMatchIndex < searchResults.length - 1 ? currentMatchIndex + 1 : 0];
|
|
onNavigateToMessage(result.message.id);
|
|
}, [searchResults, currentMatchIndex, onNavigateToMessage]);
|
|
|
|
// Handle keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Ctrl+F or Cmd+F to open search
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
e.preventDefault();
|
|
setIsOpen((prev) => !prev);
|
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
}
|
|
|
|
// Escape to close search
|
|
if (e.key === 'Escape' && isOpen) {
|
|
setIsOpen(false);
|
|
setQuery('');
|
|
}
|
|
|
|
// Enter to navigate to next match
|
|
if (e.key === 'Enter' && isOpen && searchResults.length > 0) {
|
|
if (e.shiftKey) {
|
|
handlePrevious();
|
|
} else {
|
|
handleNext();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, searchResults.length, handlePrevious, handleNext]);
|
|
|
|
// Reset current match index when results change
|
|
useEffect(() => {
|
|
setCurrentMatchIndex(0);
|
|
}, [searchResults.length]);
|
|
|
|
// Handle search submit
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (query.trim()) {
|
|
saveToHistory(query.trim());
|
|
if (searchResults.length > 0) {
|
|
onNavigateToMessage(searchResults[0].message.id);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Clear search
|
|
const handleClear = () => {
|
|
setQuery('');
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
// Toggle search panel
|
|
const toggleSearch = () => {
|
|
setIsOpen((prev) => !prev);
|
|
if (!isOpen) {
|
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Search toggle button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={toggleSearch}
|
|
className={`flex items-center gap-1.5 ${isOpen ? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20' : 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
|
title="Search messages (Ctrl+F)"
|
|
aria-label="Search messages"
|
|
aria-expanded={isOpen}
|
|
>
|
|
<Search className="w-3.5 h-3.5" />
|
|
<span className="hidden sm:inline">Search</span>
|
|
</Button>
|
|
|
|
{/* Search panel */}
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 overflow-hidden"
|
|
>
|
|
<div className="px-4 py-3">
|
|
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
|
{/* Scope toggle */}
|
|
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setScope('session')}
|
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
|
scope === 'session'
|
|
? 'bg-white dark:bg-gray-600 text-orange-600 dark:text-orange-400 shadow-sm'
|
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
|
}`}
|
|
aria-label="Search current session"
|
|
>
|
|
<MessageSquare className="w-3 h-3" />
|
|
<span className="hidden sm:inline">Session</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setScope('global')}
|
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
|
scope === 'global'
|
|
? 'bg-white dark:bg-gray-600 text-orange-600 dark:text-orange-400 shadow-sm'
|
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
|
}`}
|
|
aria-label="Search all memories"
|
|
>
|
|
<Globe className="w-3 h-3" />
|
|
<span className="hidden sm:inline">Global</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search input */}
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={scope === 'global' ? 'Search all memories...' : 'Search messages...'}
|
|
className="w-full pl-9 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400 focus:border-transparent"
|
|
aria-label="Search query"
|
|
/>
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
aria-label="Clear search"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filter toggle (session only) */}
|
|
{scope === 'session' && (
|
|
<Button
|
|
type="button"
|
|
variant={showFilters ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setShowFilters((prev) => !prev)}
|
|
className="flex items-center gap-1"
|
|
aria-label="Toggle filters"
|
|
aria-expanded={showFilters}
|
|
>
|
|
<Filter className="w-4 h-4" />
|
|
<span className="hidden sm:inline">Filters</span>
|
|
</Button>
|
|
)}
|
|
|
|
{/* Navigation buttons (session only) */}
|
|
{scope === 'session' && searchResults.length > 0 && (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">
|
|
{currentMatchIndex + 1} / {searchResults.length}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handlePrevious}
|
|
className="p-1.5"
|
|
aria-label="Previous match"
|
|
>
|
|
<ChevronUp className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleNext}
|
|
className="p-1.5"
|
|
aria-label="Next match"
|
|
>
|
|
<ChevronDown className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
|
|
{/* Filters panel */}
|
|
<AnimatePresence>
|
|
{showFilters && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
|
|
>
|
|
<div className="flex flex-wrap gap-4">
|
|
{/* Sender filter */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
<User className="w-3.5 h-3.5" />
|
|
Sender:
|
|
</label>
|
|
<select
|
|
value={filters.sender}
|
|
onChange={(e) => setFilters((prev) => ({ ...prev, sender: e.target.value as SearchFilters['sender'] }))}
|
|
className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
|
>
|
|
<option value="all">All</option>
|
|
<option value="user">User</option>
|
|
<option value="assistant">Assistant</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Time range filter */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
<Clock className="w-3.5 h-3.5" />
|
|
Time:
|
|
</label>
|
|
<select
|
|
value={filters.timeRange}
|
|
onChange={(e) => setFilters((prev) => ({ ...prev, timeRange: e.target.value as SearchFilters['timeRange'] }))}
|
|
className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
|
>
|
|
<option value="all">All time</option>
|
|
<option value="today">Today</option>
|
|
<option value="week">This week</option>
|
|
<option value="month">This month</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Search history */}
|
|
{!query && searchHistory.length > 0 && (
|
|
<div className="mt-2">
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">Recent searches:</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{searchHistory.slice(0, 5).map((item, index) => (
|
|
<button
|
|
key={index}
|
|
type="button"
|
|
onClick={() => setQuery(item)}
|
|
className="text-xs px-2 py-1 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
{item}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Global search results */}
|
|
{scope === 'global' && query && (
|
|
<div className="mt-2 max-h-64 overflow-y-auto">
|
|
{globalLoading && (
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
|
Searching memories...
|
|
</div>
|
|
)}
|
|
{!globalLoading && globalResults.length === 0 && (
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
|
No memories found matching "{query}"
|
|
</div>
|
|
)}
|
|
{!globalLoading && globalResults.length > 0 && (
|
|
<div className="space-y-1">
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">
|
|
{globalResults.length} memories found
|
|
</div>
|
|
{globalResults.map((result) => (
|
|
<div
|
|
key={result.memory.id}
|
|
className="px-2 py-1.5 bg-white dark:bg-gray-700/50 border border-gray-100 dark:border-gray-600 rounded text-xs"
|
|
>
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<span className="text-orange-500 dark:text-orange-400 font-medium">
|
|
{result.memory.memory_type}
|
|
</span>
|
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
|
<span className="text-gray-400 dark:text-gray-500">
|
|
{result.memory.agent_id}
|
|
</span>
|
|
{result.memory.importance > 5 && (
|
|
<span className="text-yellow-500">
|
|
{'*'.repeat(Math.min(result.memory.importance - 4, 5))}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-gray-700 dark:text-gray-300 line-clamp-2">
|
|
{highlightSearchMatches(result.memory.content, query)}
|
|
</div>
|
|
<div className="text-gray-400 dark:text-gray-500 mt-0.5">
|
|
{result.memory.created_at.split('T')[0]}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* No results message (session search) */}
|
|
{scope === 'session' && query && searchResults.length === 0 && (
|
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
|
No messages found matching "{query}"
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Utility function to highlight search matches in text
|
|
export function highlightSearchMatches(
|
|
text: string,
|
|
query: string,
|
|
highlightClassName: string = 'bg-yellow-200 dark:bg-yellow-700/50 rounded px-0.5'
|
|
): React.ReactNode[] {
|
|
if (!query.trim()) return [text];
|
|
|
|
const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
if (searchTerms.length === 0) return [text];
|
|
|
|
const lowerText = text.toLowerCase();
|
|
const matches: Array<{ start: number; end: number }> = [];
|
|
|
|
// Find all matches
|
|
for (const term of searchTerms) {
|
|
let startIndex = 0;
|
|
while (true) {
|
|
const index = lowerText.indexOf(term, startIndex);
|
|
if (index === -1) break;
|
|
matches.push({ start: index, end: index + term.length });
|
|
startIndex = index + 1;
|
|
}
|
|
}
|
|
|
|
if (matches.length === 0) return [text];
|
|
|
|
// Sort and merge overlapping matches
|
|
matches.sort((a, b) => a.start - b.start);
|
|
const merged: Array<{ start: number; end: number }> = [];
|
|
for (const match of matches) {
|
|
if (merged.length === 0 || merged[merged.length - 1].end < match.start) {
|
|
merged.push({ ...match });
|
|
} else {
|
|
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, match.end);
|
|
}
|
|
}
|
|
|
|
// Build highlighted result
|
|
const result: React.ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
|
|
for (let i = 0; i < merged.length; i++) {
|
|
const match = merged[i];
|
|
|
|
// Text before match
|
|
if (match.start > lastIndex) {
|
|
result.push(text.slice(lastIndex, match.start));
|
|
}
|
|
|
|
// Highlighted match
|
|
result.push(
|
|
<mark key={i} className={highlightClassName}>
|
|
{text.slice(match.start, match.end)}
|
|
</mark>
|
|
);
|
|
|
|
lastIndex = match.end;
|
|
}
|
|
|
|
// Remaining text
|
|
if (lastIndex < text.length) {
|
|
result.push(text.slice(lastIndex));
|
|
}
|
|
|
|
return result;
|
|
}
|