docs(claude): restructure documentation management and add feedback system

- Restructure §8 from "文档沉淀规则" to "文档管理规则" with 4 subsections
  - Add docs/ structure with features/ and knowledge-base/ directories
  - Add feature documentation template with 7 sections (概述/设计初衷/技术设计/预期作用/实际效果/演化路线/头脑风暴)
  - Add feature update trigger matrix (新增/修改/完成/问题/反馈)
  - Add documentation quality checklist
- Add §16
This commit is contained in:
iven
2026-03-16 13:54:03 +08:00
parent 8e630882c7
commit adfd7024df
44 changed files with 10491 additions and 248 deletions

View File

@@ -8,6 +8,7 @@ import { SettingsLayout } from './components/Settings/SettingsLayout';
import { HandTaskPanel } from './components/HandTaskPanel';
import { SchedulerPanel } from './components/SchedulerPanel';
import { TeamCollaborationView } from './components/TeamCollaborationView';
import { SwarmDashboard } from './components/SwarmDashboard';
import { useGatewayStore } from './store/gatewayStore';
import { useTeamStore } from './store/teamStore';
import { getStoredGatewayToken } from './lib/gateway-client';
@@ -110,6 +111,15 @@ function App() {
description="Choose a team from the list on the left, or click + to create a new multi-Agent collaboration team."
/>
)
) : mainContentView === 'swarm' ? (
<motion.div
variants={fadeInVariants}
initial="initial"
animate="animate"
className="flex-1 overflow-hidden"
>
<SwarmDashboard />
</motion.div>
) : (
<ChatArea />
)}

View File

@@ -0,0 +1,40 @@
import { MessageCircle } from 'lucide-react';
import { motion } from 'framer-motion';
import { useFeedbackStore } from './feedbackStore';
import { Button } from '../ui';
interface FeedbackButtonProps {
onClick: () => void;
showCount?: boolean;
}
export function FeedbackButton({ onClick, showCount = true }: FeedbackButtonProps) {
const feedbackItems = useFeedbackStore((state) => state.feedbackItems);
const pendingCount = feedbackItems.filter((f) => f.status === 'pending' || f.status === 'submitted').length;
return (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
variant="ghost"
size="sm"
onClick={onClick}
className="relative flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
<MessageCircle className="w-4 h-4" />
<span className="text-sm">Feedback</span>
{showCount && pendingCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-1 -right-1 w-4 h-4 bg-orange-500 text-white text-[10px] rounded-full flex items-center justify-center"
>
{pendingCount > 9 ? '9+' : pendingCount}
</motion.span>
)}
</Button>
</motion.div>
);
}

View File

@@ -0,0 +1,193 @@
import { format } from 'date-fns';
import { motion, AnimatePresence } from 'framer-motion';
import { Clock, CheckCircle, AlertCircle, Hourglass, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { useFeedbackStore, type FeedbackSubmission, type FeedbackStatus } from './feedbackStore';
import { Button, Badge } from '../ui';
const statusConfig: Record<FeedbackStatus, { label: string; color: string; icon: React.ReactNode }> = {
pending: { label: 'Pending', color: 'text-gray-500', icon: <Clock className="w-4 h-4" /> },
submitted: { label: 'Submitted', color: 'text-blue-500', icon: <CheckCircle className="w-4 h-4" /> },
acknowledged: { label: 'Acknowledged', color: 'text-purple-500', icon: <CheckCircle className="w-4 h-4" /> },
in_progress: { label: 'In Progress', color: 'text-yellow-500', icon: <Hourglass className="w-4 h-4" /> },
resolved: { label: 'Resolved', color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" /> },
};
const typeLabels: Record<string, string> = {
bug: 'Bug Report',
feature: 'Feature Request';
general: 'General Feedback',
};
const priorityLabels: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
};
interface FeedbackHistoryProps {
onViewDetails?: (feedback: FeedbackSubmission) => void;
}
export function FeedbackHistory({ onViewDetails }: FeedbackHistoryProps) {
const { feedbackItems, deleteFeedback, updateFeedbackStatus } = useFeedbackStore();
const [expandedId, setExpandedId] = useState<string | null>(null);
const formatDate = (timestamp: number) => {
return format(new Date(timestamp), 'yyyy-MM-dd HH:mm');
};
const handleDelete = (id: string) => {
if (confirm('Are you sure you want to delete this feedback?')) {
deleteFeedback(id);
}
};
const handleStatusChange = (id: string, newStatus: FeedbackStatus) => {
updateFeedbackStatus(id, newStatus);
};
if (feedbackItems.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No feedback submissions yet.</p>
<p className="text-sm mt-1">Click the feedback button to submit your first feedback.</p>
</div>
);
}
return (
<div className="space-y-3">
{feedbackItems.map((feedback) => {
const isExpanded = expandedId === feedback.id;
const statusInfo = statusConfig[feedback.status];
return (
<motion.div
key={feedback.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => setExpandedId(isExpanded ? null : feedback.id)}
>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
{feedback.type === 'bug' && <span className="text-red-500"><AlertCircle className="w-4 h-4" /></span>}
{feedback.type === 'feature' && <span className="text-yellow-500"><ChevronUp className="w-4 h-4" /></span>}
{feedback.type === 'general' && <span className="text-blue-500"><CheckCircle className="w-4 h-4" /></span>}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{feedback.title}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{typeLabels[feedback.type]} - {formatDate(feedback.createdAt)}
</p>
</div>
<Badge variant={feedback.priority === 'high' ? 'error' : feedback.priority === 'medium' ? 'warning' : 'default'}>
{priorityLabels[feedback.priority]}
</Badge>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setExpandedId(isExpanded ? null : feedback.id);
}}
className="text-gray-400 hover:text-gray-600 p-1"
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
{/* Expandable Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-4 pb-3 border-t border-gray-100 dark:border-gray-700"
>
<div className="space-y-3">
{/* Description */}
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</h5>
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{feedback.description}
</p>
</div>
{/* Attachments */}
{feedback.attachments.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Attachments ({feedback.attachments.length})
</h5>
<div className="flex flex-wrap gap-2 mt-1">
{feedback.attachments.map((att, idx) => (
<span
key={idx}
className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded"
>
{att.name}
</span>
))}
</div>
</div>
)}
{/* Metadata */}
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">System Info</h5>
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p>App Version: {feedback.metadata.appVersion}</p>
<p>OS: {feedback.metadata.os}</p>
<p>Submitted: {format(feedback.createdAt)}</p>
</div>
</div>
{/* Status and Actions */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className={`flex items-center gap-1 text-xs ${statusInfo.color}`}>
{statusInfo.icon}
{statusInfo.label}
</span>
</div>
<div className="flex items-center gap-2">
<select
value={feedback.status}
onChange={(e) => handleStatusChange(feedback.id, e.target.value as FeedbackStatus)}
className="text-xs border border-gray-200 dark:border-gray-600 rounded px-2 py-1 bg-white dark:bg-gray-800"
>
<option value="pending">Pending</option>
<option value="submitted">Submitted</option>
<option value="acknowledged">Acknowledged</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(feedback.id)}
className="text-red-500 hover:text-red-600"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,292 @@
import { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Send, Bug, Lightbulb, MessageSquare, AlertCircle, Upload, Trash2 } from 'lucide-react';
import { useFeedbackStore, type FeedbackType, type FeedbackPriority } from './feedbackStore';
import { Button } from '../ui';
import { useToast } from '../ui/Toast';
interface FeedbackModalProps {
onClose: () => void;
}
const typeOptions: { value: FeedbackType; label: string; icon: React.ReactNode }[] = [
{ value: 'bug', label: 'Bug Report', icon: <Bug className="w-4 h-4" /> },
{ value: 'feature', label: 'Feature Request', icon: <Lightbulb className="w-4 h-4" /> },
{ value: 'general', label: 'General Feedback', icon: <MessageSquare className="w-4 h-4" /> },
];
const priorityOptions: { value: FeedbackPriority; label: string; color: string }[] = [
{ value: 'low', label: 'Low', color: 'text-gray-500' },
{ value: 'medium', label: 'Medium', color: 'text-yellow-600' },
{ value: 'high', label: 'High', color: 'text-red-500' },
];
export function FeedbackModal({ onClose }: FeedbackModalProps) {
const { submitFeedback, isLoading, error } = useFeedbackStore();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [type, setType] = useState<FeedbackType>('bug');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<FeedbackPriority>('medium');
const [attachments, setAttachments] = useState<File[]>([]);
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) {
toast('Please fill in title and description', 'warning');
return;
}
// Convert files to base64 for storage
const processedAttachments = await Promise.all(
attachments.map(async (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
name: file.name,
type: file.type,
size: file.size,
data: reader.result as string,
});
};
reader.readAsDataURL(file);
});
})
);
try {
const result = await submitFeedback({
type,
title: title.trim(),
description: description.trim(),
priority,
attachments: processedAttachments,
metadata: {
appVersion: '0.0.0',
os: navigator.platform,
timestamp: Date.now(),
},
});
if (result) {
toast('Feedback submitted successfully!', 'success');
// Reset form
setTitle('');
setDescription('');
setAttachments([]);
setType('bug');
setPriority('medium');
onClose();
}
} catch (err) {
toast('Failed to submit feedback. Please try again.', 'error');
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
// Limit to 5 attachments
const newFiles = [...attachments, ...files].slice(0, 5);
setAttachments(newFiles);
};
const removeAttachment = (index: number) => {
setAttachments(attachments.filter((_, i) => i !== index));
};
const 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`;
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby="feedback-title"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="feedback-title" className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Submit Feedback
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-4 space-y-4">
{/* Type Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Feedback Type
</label>
<div className="flex gap-2">
{typeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setType(opt.value)}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all ${
type === opt.value
? 'border-orange-400 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400'
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label htmlFor="feedback-title-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title
</label>
<input
id="feedback-title-input"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Brief summary of your feedback"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-400 dark:bg-gray-700 dark:text-gray-100"
maxLength={100}
/>
</div>
{/* Description */}
<div>
<label htmlFor="feedback-desc-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea
id="feedback-desc-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Please describe your feedback in detail. For bugs, include steps to reproduce."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-400 dark:bg-gray-700 dark:text-gray-100 resize-none"
rows={4}
maxLength={2000}
/>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priority
</label>
<div className="flex gap-2">
{priorityOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setPriority(opt.value)}
className={`flex-1 px-3 py-2 rounded-lg border text-sm transition-all ${
priority === opt.value
? 'border-orange-400 bg-orange-50 dark:bg-orange-900/20 font-medium'
: 'border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
} ${opt.color}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Attachments */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Attachments (optional, max 5)
</label>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-2 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Upload className="w-4 h-4" />
Add Screenshots
</button>
{attachments.length > 0 && (
<div className="mt-2 space-y-1">
{attachments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between px-2 py-1 bg-gray-50 dark:bg-gray-700 rounded text-xs"
>
<span className="truncate text-gray-600 dark:text-gray-300">
{file.name} ({formatFileSize(file.size)})
</span>
<button
onClick={() => removeAttachment(index)}
className="text-gray-400 hover:text-red-500"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
{/* Error Display */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-500 bg-red-50 dark:bg-red-900/20 px-3 py-2 rounded-lg">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={() => { handleSubmit().catch(() => {}); }}
loading={isLoading}
disabled={!title.trim() || !description.trim()}
>
<Send className="w-4 h-4 mr-2" />
Submit
</Button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,143 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Types
export type FeedbackType = 'bug' | 'feature' | 'general';
export type FeedbackPriority = 'low' | 'medium' | 'high';
export type FeedbackStatus = 'pending' | 'submitted' | 'acknowledged' | 'in_progress' | 'resolved';
export interface FeedbackAttachment {
name: string;
type: string;
size: number;
data: string; // base64 encoded
}
export interface FeedbackSubmission {
id: string;
type: FeedbackType;
title: string;
description: string;
priority: FeedbackPriority;
status: FeedbackStatus;
attachments: FeedbackAttachment[];
metadata: {
appVersion: string;
os: string;
timestamp: number;
userAgent?: string;
};
createdAt: number;
updatedAt: number;
}
interface FeedbackState {
feedbackItems: FeedbackSubmission[];
isModalOpen: boolean;
isLoading: boolean;
error: string | null;
}
interface FeedbackActions {
openModal: () => void;
closeModal: () => void;
submitFeedback: (feedback: Omit<FeedbackSubmission, 'id' | 'createdAt' | 'updatedAt' | 'status'>) => Promise<void>;
updateFeedbackStatus: (id: string, status: FeedbackStatus) => void;
deleteFeedback: (id: string) => void;
clearError: () => void;
}
export type FeedbackStore = FeedbackState & FeedbackActions;
const STORAGE_KEY = 'zclaw-feedback-history';
const MAX_FEEDBACK_ITEMS = 100;
// Helper to get app metadata
function getAppMetadata() {
return {
appVersion: '0.0.0',
os: typeof navigator !== 'undefined' ? navigator.platform : 'unknown',
timestamp: Date.now(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
};
}
// Generate unique ID
function generateFeedbackId(): string {
return `fb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export const useFeedbackStore = create<FeedbackStore>()(
persist(
(set, get) => ({
feedbackItems: [],
isModalOpen: false,
isLoading: false,
error: null,
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
submitFeedback: async (feedback) => {
const { feedbackItems } = get();
set({ isLoading: true, error: null });
try {
const newFeedback: FeedbackSubmission = {
...feedback,
id: generateFeedbackId(),
createdAt: Date.now(),
updatedAt: Date.now(),
status: 'submitted',
metadata: {
...feedback.metadata,
...getAppMetadata(),
},
};
// Simulate async submission
await new Promise(resolve => setTimeout(resolve, 300));
// Keep only MAX_FEEDBACK_ITEMS
const updatedItems = [newFeedback, ...feedbackItems].slice(0, MAX_FEEDBACK_ITEMS);
set({
feedbackItems: updatedItems,
isLoading: false,
isModalOpen: false,
});
return newFeedback;
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to submit feedback',
});
throw err;
}
},
updateFeedbackStatus: (id, status) => {
const { feedbackItems } = get();
const updatedItems = feedbackItems.map(item =>
item.id === id
? { ...item, status, updatedAt: Date.now() }
: item
);
set({ feedbackItems: updatedItems });
},
deleteFeedback: (id) => {
const { feedbackItems } = get();
set({
feedbackItems: feedbackItems.filter(item => item.id !== id),
});
},
clearError: () => set({ error: null }),
}),
{
name: STORAGE_KEY,
}
)
);

View File

@@ -0,0 +1,11 @@
export { FeedbackButton } from './FeedbackButton';
export { FeedbackModal } from './FeedbackModal';
export { FeedbackHistory } from './FeedbackHistory';
export {
useFeedbackStore,
type FeedbackSubmission,
type FeedbackType,
type FeedbackPriority,
type FeedbackStatus,
type FeedbackAttachment,
} from './feedbackStore';

View File

@@ -0,0 +1,464 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Search, X, ChevronUp, ChevronDown, Clock, User, Bot, Filter } from 'lucide-react';
import { Button } from './ui';
import { useChatStore, Message } from '../store/chatStore';
export interface SearchFilters {
sender: 'all' | 'user' | 'assistant';
timeRange: 'all' | 'today' | 'week' | 'month';
}
export interface SearchResult {
message: Message;
matchIndices: Array<{ start: number; end: number }>;
}
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 [filters, setFilters] = useState<SearchFilters>({
sender: 'all',
timeRange: 'all',
});
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const [showFilters, setShowFilters] = useState(false);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
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;
});
}, []);
// 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">
{/* 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="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 */}
<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 */}
{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>
)}
{/* No results message */}
{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;
}

View File

@@ -1,13 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import { getStoredGatewayUrl } from '../lib/gateway-client';
import { useGatewayStore, type PluginStatus } from '../store/gatewayStore';
import { toChatAgent, useChatStore } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain, MessageCircle
} from 'lucide-react';
import { MemoryPanel } from './MemoryPanel';
import { FeedbackModal, FeedbackHistory } from './Feedback';
import { cardHover, defaultTransition } from '../lib/animations';
import { Button, Badge, EmptyState } from './ui';
@@ -17,7 +18,8 @@ export function RightPanel() {
connect, loadClones, loadUsageStats, loadPluginStatus, workspaceInfo, quickConfig, updateClone,
} = useGatewayStore();
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory'>('status');
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'feedback'>('status');
const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false);
const [isEditingAgent, setIsEditingAgent] = useState(false);
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
@@ -152,6 +154,18 @@ export function RightPanel() {
>
<Brain className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'feedback' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setActiveTab('feedback')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Feedback"
aria-label="Feedback"
aria-selected={activeTab === 'feedback'}
role="tab"
>
<MessageCircle className="w-4 h-4" />
</Button>
</div>
</div>
@@ -382,6 +396,29 @@ export function RightPanel() {
)}
</motion.div>
</div>
) : activeTab === 'feedback' ? (
<div className="space-y-4">
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<MessageCircle className="w-4 h-4" />
User Feedback
</h3>
<Button
variant="primary"
size="sm"
onClick={() => setIsFeedbackModalOpen(true)}
>
New Feedback
</Button>
</div>
<FeedbackHistory />
</motion.div>
</div>
) : (
<>
{/* Gateway 连接状态 */}
@@ -592,6 +629,13 @@ export function RightPanel() {
</>
)}
</div>
{/* Feedback Modal */}
<AnimatePresence>
{isFeedbackModalOpen && (
<FeedbackModal onClose={() => setIsFeedbackModalOpen(false)} />
)}
</AnimatePresence>
</aside>
);
}

View File

@@ -1,15 +1,16 @@
import { useState } from 'react';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Settings, Users, Bot, GitBranch, MessageSquare } from 'lucide-react';
import { Settings, Users, Bot, GitBranch, MessageSquare, Layers } from 'lucide-react';
import { CloneManager } from './CloneManager';
import { HandList } from './HandList';
import { TaskList } from './TaskList';
import { TeamList } from './TeamList';
import { SwarmDashboard } from './SwarmDashboard';
import { useGatewayStore } from '../store/gatewayStore';
import { Button } from './ui';
import { containerVariants, defaultTransition } from '../lib/animations';
export type MainViewType = 'chat' | 'hands' | 'workflow' | 'team';
export type MainViewType = 'chat' | 'hands' | 'workflow' | 'team' | 'swarm';
interface SidebarProps {
onOpenSettings?: () => void;
@@ -20,13 +21,14 @@ interface SidebarProps {
onSelectTeam?: (teamId: string) => void;
}
type Tab = 'clones' | 'hands' | 'workflow' | 'team';
type Tab = 'clones' | 'hands' | 'workflow' | 'team' | 'swarm';
const TABS: { key: Tab; label: string; icon: React.ComponentType<{ className?: string }>; mainView?: MainViewType }[] = [
{ key: 'clones', label: '分身', icon: Bot },
{ key: 'hands', label: 'Hands', icon: MessageSquare, mainView: 'hands' },
{ key: 'workflow', label: '工作流', icon: GitBranch, mainView: 'workflow' },
{ key: 'team', label: '团队', icon: Users, mainView: 'team' },
{ key: 'swarm', label: '协作', icon: Layers, mainView: 'swarm' },
];
export function Sidebar({
@@ -55,6 +57,12 @@ export function Sidebar({
onMainViewChange?.('hands');
};
const handleSelectTeam = (teamId: string) => {
onSelectTeam?.(teamId);
setActiveTab('team');
onMainViewChange?.('team');
};
return (
<aside className="w-64 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
{/* 顶部标签 - 使用图标 */}
@@ -102,9 +110,10 @@ export function Sidebar({
{activeTab === 'team' && (
<TeamList
selectedTeamId={selectedTeamId}
onSelectTeam={onSelectTeam}
onSelectTeam={handleSelectTeam}
/>
)}
{activeTab === 'swarm' && <SwarmDashboard />}
</motion.div>
</AnimatePresence>
</div>

View File

@@ -12,7 +12,6 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
Users,
Play,
Pause,
CheckCircle,
XCircle,
Clock,
@@ -211,7 +210,7 @@ function TaskCard({
onSelect: () => void;
}) {
const [expandedSubtasks, setExpandedSubtasks] = useState<Set<string>>(new Set());
const { agents } = useAgentStore();
const { clones } = useAgentStore();
const toggleSubtask = useCallback((subtaskId: string) => {
setExpandedSubtasks((prev) => {
@@ -234,7 +233,7 @@ function TaskCard({
}, [task.createdAt, task.completedAt]);
const getAgentName = (agentId: string) => {
const agent = agents.find((a) => a.id === agentId);
const agent = clones.find((a) => a.id === agentId);
return agent?.name || agentId;
};

View File

@@ -0,0 +1,345 @@
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
AlertTriangle,
Wifi,
Shield,
Clock,
Settings,
AlertCircle,
ChevronDown,
ChevronUp,
Copy,
CheckCircle,
ExternalLink,
} from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './Button';
import {
AppError,
ErrorCategory,
classifyError,
formatErrorForClipboard,
getErrorIcon as getIconByCategory,
getErrorColor as getColorByCategory,
} from '../../lib/error-types';
import { reportError } from '../../lib/error-handling';
// === Props ===
export interface ErrorAlertProps {
error: AppError | string | Error | null;
onDismiss?: () => void;
onRetry?: () => void;
showTechnicalDetails?: boolean;
className?: string;
compact?: boolean;
}
interface ErrorAlertState {
showDetails: boolean;
copied: boolean;
}
// === Category Configuration ===
const CATEGORY_CONFIG: Record<ErrorCategory, {
icon: typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle;
color: string;
bgColor: string;
label: string;
}> = {
network: {
icon: Wifi,
color: 'text-orange-500',
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
label: 'Network',
},
auth: {
icon: Shield,
color: 'text-red-500',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'Authentication',
},
permission: {
icon: Shield,
color: 'text-purple-500',
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
label: 'Permission',
},
validation: {
icon: AlertCircle,
color: 'text-yellow-600',
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
label: 'Validation',
},
timeout: {
icon: Clock,
color: 'text-amber-500',
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
label: 'Timeout',
},
server: {
icon: AlertTriangle,
color: 'text-red-500',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'Server',
},
client: {
icon: AlertCircle,
color: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
label: 'Client',
},
config: {
icon: Settings,
color: 'text-gray-500',
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
label: 'Configuration',
},
system: {
icon: AlertTriangle,
color: 'text-red-600',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'System',
},
};
/**
* Get icon component for error category
*/
export function getIconByCategory(category: ErrorCategory) typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].icon : AlertCircle;
}
/**
* Get color class for error category
*/
export function getColorByCategory(category: ErrorCategory) string {
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].color : 'text-gray-500';
}
/**
* ErrorAlert Component
*
* Displays detailed error information with recovery suggestions,
* technical details, and action buttons.
*/
export function ErrorAlert({
error: errorProp,
onDismiss,
onRetry,
showTechnicalDetails = true,
className,
compact = false,
}: ErrorAlertProps) {
const [state, setState] = useState<ErrorAlertState>({
showDetails: false,
copied: false,
});
// Normalize error input
const appError = typeof error === 'string'
? classifyError(new Error(error))
: error instanceof Error
? classifyError(error)
: error;
const {
category,
title,
message,
technicalDetails,
recoverable,
recoverySteps,
timestamp,
} = appError;
const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.system!;
const IconComponent = config.icon;
const handleCopyDetails = useCallback(async () => {
const text = formatErrorForClipboard(appError);
try {
await navigator.clipboard.writeText(text);
setState({ copied: true });
setTimeout(() => setState({ copied: false }), 2000);
} catch (err) {
console.error('Failed to copy error details:', err);
}
}, [appError]);
const handleReport = useCallback(() => {
reportError(appError.originalError || appError, {
errorId: appError.id,
category: appError.category,
title: appError.title,
message: appError.message,
timestamp: appError.timestamp.toISOString(),
});
}, [appError]);
const toggleDetails = useCallback(() => {
setState((prev) => ({ showDetails: !prev.showDetails }));
}, []);
const handleRetry = useCallback(() => {
onRetry?.();
}, [onRetry]);
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
'rounded-lg border overflow-hidden',
config.bgColor,
'border-gray-200 dark:border-gray-700',
className
)}
>
{/* Header */}
<div className="flex items-start gap-3 p-3 bg-white/50 dark:bg-gray-800/50">
<div className={cn('p-2 rounded-lg', config.bgColor)}>
<IconComponent className={cn('w-5 h-5', config.color)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn('text-xs font-medium', config.color)}>
{config.label}
</span>
<span className="text-xs text-gray-400">
{timestamp.toLocaleTimeString()}
</span>
</div>
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mt-1">
{title}
</h4>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
aria-label="Dismiss"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Body */}
<div className="px-3 pb-2">
<p className={cn(
'text-gray-700 dark:text-gray-300',
compact ? 'text-sm line-clamp-2' : 'text-sm'
)}>
{message}
</p>
{/* Recovery Steps */}
{recoverySteps.length > 0 && !compact && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Recovery Suggestions
</p>
<ul className="space-y-1">
{recoverySteps.slice(0, 3).map((step, index) => (
<li key={index} className="text-xs text-gray-600 dark:text-gray-400 flex items-start gap-2">
<span className="text-gray-400">-</span>
{step.description}
{step.action && step.label && (
<button
onClick={step.action}
className="text-blue-500 hover:text-blue-600 ml-1"
>
{step.label}
</button>
)}
</li>
))}
</ul>
</div>
)}
{/* Technical Details Toggle */}
{showTechnicalDetails && technicalDetails && !compact && (
<div className="mt-2">
<button
onClick={toggleDetails}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
{state.showDetails ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
Technical Details
</button>
<AnimatePresence>
{state.showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<pre className="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap break-all">
{technicalDetails}
</pre>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between gap-2 p-3 pt-2 border-t border-gray-100 dark:border-gray-700 bg-white/30 dark:bg-gray-800/30">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCopyDetails}
className="text-xs"
>
{state.copied ? (
<>
<CheckCircle className="w-3 h-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReport}
className="text-xs"
>
<ExternalLink className="w-3 h-3 mr-1" />
Report
</Button>
</div>
{recoverable && onRetry && (
<Button
variant="primary"
size="sm"
onClick={handleRetry}
className="text-xs"
>
Try Again
</Button>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,179 @@
import { Component, ReactNode, ErrorInfo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle, RefreshCcw, Bug, Home } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './Button';
import { reportError } from '../../lib/error-handling';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
/**
* ErrorBoundary Component
*
* Catches React rendering errors and displays a friendly error screen
* with recovery options and error reporting.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): ErrorInfo {
return {
componentStack: error.stack || 'No stack trace available',
errorName: error.name || 'Unknown Error',
errorMessage: error.message || 'An unexpected error occurred',
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const { onError } = this.props;
// Call optional error handler
if (onError) {
onError(error, errorInfo);
}
// Update state to show error UI
this.setState({
hasError: true,
error,
errorInfo: {
componentStack: errorInfo.componentStack,
errorName: errorInfo.errorName || error.name || 'Unknown Error',
errorMessage: errorInfo.errorMessage || error.message || 'An unexpected error occurred',
},
});
}
handleReset = () => {
const { onReset } = this.props;
// Reset error state
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
// Call optional reset handler
if (onReset) {
onReset();
}
};
handleReport = () => {
const { error, errorInfo } = this.state;
if (error && errorInfo) {
reportError(error, {
componentStack: errorInfo.componentStack,
errorName: errorInfo.errorName,
errorMessage: errorInfo.errorMessage,
});
}
};
handleGoHome = () => {
// Navigate to home/main view
window.location.href = '/';
};
render() {
const { children, fallback } = this.props;
const { hasError, error, errorInfo } = this.state;
if (hasError && error) {
// Use custom fallback if provided
if (fallback) {
return fallback;
}
// Default error UI
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden"
>
{/* Error Icon */}
<div className="flex items-center justify-center w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full mx-4">
<AlertTriangle className="w-8 h-8 text-red-500" />
</div>
{/* Content */}
<div className="p-6 text-center">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Something went wrong
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{errorInfo?.errorMessage || error.message || 'An unexpected error occurred'}
</p>
{/* Error Details */}
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg text-left">
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{errorInfo?.errorName || 'Unknown Error'}
</p>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 mt-6">
<Button
variant="primary"
size="sm"
onClick={this.handleReset}
className="w-full"
>
<RefreshC className="w-4 h-4 mr-2" />
Try Again
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={this.handleReport}
className="flex-1"
>
<Bug className="w-4 h-4 mr-2" />
Report Issue
</Button>
<Button
variant="ghost"
size="sm"
onClick={this.handleGoHome}
className="flex-1"
>
<Home className="w-4 h-4 mr-2" />
Go Home
</Button>
</div>
</div>
</motion.div>
</div>
);
}
return children;
}
}
}

View File

@@ -2,11 +2,14 @@
* Agent Memory System - Persistent cross-session memory for ZCLAW agents
*
* Phase 1 implementation: zustand persist (localStorage) with keyword search.
* Optimized with inverted index for sub-20ms retrieval on 1000+ memories.
* Designed for easy upgrade to SQLite + FTS5 + vector search in Phase 2.
*
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.1
*/
import { MemoryIndex, getMemoryIndex, resetMemoryIndex, tokenize } from './memory-index';
// === Types ===
export type MemoryType = 'fact' | 'preference' | 'lesson' | 'context' | 'task';
@@ -41,6 +44,10 @@ export interface MemoryStats {
byAgent: Record<string, number>;
oldestEntry: string | null;
newestEntry: string | null;
indexStats?: {
cacheHitRate: number;
avgQueryTime: number;
};
}
// === Memory ID Generator ===
@@ -51,16 +58,13 @@ function generateMemoryId(): string {
// === Keyword Search Scoring ===
function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf]+/g, ' ')
.split(/\s+/)
.filter(t => t.length > 0);
}
function searchScore(entry: MemoryEntry, queryTokens: string[]): number {
const contentTokens = tokenize(entry.content);
function searchScore(
entry: MemoryEntry,
queryTokens: string[],
cachedTokens?: string[]
): number {
// Use cached tokens if available, otherwise tokenize
const contentTokens = cachedTokens ?? tokenize(entry.content);
const tagTokens = entry.tags.flatMap(t => tokenize(t));
const allTokens = [...contentTokens, ...tagTokens];
@@ -86,9 +90,13 @@ const STORAGE_KEY = 'zclaw-agent-memories';
export class MemoryManager {
private entries: MemoryEntry[] = [];
private entryIndex: Map<string, number> = new Map(); // id -> array index for O(1) lookup
private memoryIndex: MemoryIndex;
private indexInitialized = false;
constructor() {
this.load();
this.memoryIndex = getMemoryIndex();
}
// === Persistence ===
@@ -98,6 +106,10 @@ export class MemoryManager {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
this.entries = JSON.parse(raw);
// Build entry index for O(1) lookups
this.entries.forEach((entry, index) => {
this.entryIndex.set(entry.id, index);
});
}
} catch (err) {
console.warn('[MemoryManager] Failed to load memories:', err);
@@ -113,6 +125,26 @@ export class MemoryManager {
}
}
// === Index Management ===
private ensureIndexInitialized(): void {
if (!this.indexInitialized) {
this.memoryIndex.rebuild(this.entries);
this.indexInitialized = true;
}
}
private indexEntry(entry: MemoryEntry): void {
this.ensureIndexInitialized();
this.memoryIndex.index(entry);
}
private removeEntryFromIndex(id: string): void {
if (this.indexInitialized) {
this.memoryIndex.remove(id);
}
}
// === Write ===
async save(
@@ -141,51 +173,90 @@ export class MemoryManager {
duplicate.lastAccessedAt = now;
duplicate.accessCount++;
duplicate.tags = [...new Set([...duplicate.tags, ...entry.tags])];
// Re-index the updated entry
this.indexEntry(duplicate);
this.persist();
return duplicate;
}
this.entries.push(newEntry);
this.entryIndex.set(newEntry.id, this.entries.length - 1);
this.indexEntry(newEntry);
this.persist();
return newEntry;
}
// === Search ===
// === Search (Optimized with Index) ===
async search(query: string, options?: MemorySearchOptions): Promise<MemoryEntry[]> {
const startTime = performance.now();
const queryTokens = tokenize(query);
if (queryTokens.length === 0) return [];
let candidates = [...this.entries];
this.ensureIndexInitialized();
// Filter by options
if (options?.agentId) {
candidates = candidates.filter(e => e.agentId === options.agentId);
}
if (options?.type) {
candidates = candidates.filter(e => e.type === options.type);
}
if (options?.types && options.types.length > 0) {
candidates = candidates.filter(e => options.types!.includes(e.type));
}
if (options?.tags && options.tags.length > 0) {
candidates = candidates.filter(e =>
options.tags!.some(tag => e.tags.includes(tag))
);
}
if (options?.minImportance !== undefined) {
candidates = candidates.filter(e => e.importance >= options.minImportance!);
// Check query cache first
const cached = this.memoryIndex.getCached(query, options);
if (cached) {
// Retrieve entries by IDs
const results = cached
.map(id => this.entries[this.entryIndex.get(id) ?? -1])
.filter((e): e is MemoryEntry => e !== undefined);
this.memoryIndex.recordQueryTime(performance.now() - startTime);
return results;
}
// Score and rank
// Get candidate IDs using index (O(1) lookups)
const candidateIds = this.memoryIndex.getCandidates(options || {});
// If no candidates from index, return empty
if (candidateIds && candidateIds.size === 0) {
this.memoryIndex.setCached(query, options, []);
this.memoryIndex.recordQueryTime(performance.now() - startTime);
return [];
}
// Build candidates list
let candidates: MemoryEntry[];
if (candidateIds) {
// Use indexed candidates
candidates = [];
for (const id of candidateIds) {
const idx = this.entryIndex.get(id);
if (idx !== undefined) {
const entry = this.entries[idx];
// Additional filter for minImportance (not handled by index)
if (options?.minImportance !== undefined && entry.importance < options.minImportance) {
continue;
}
candidates.push(entry);
}
}
} else {
// Fallback: no index-based candidates, use all entries
candidates = [...this.entries];
// Apply minImportance filter
if (options?.minImportance !== undefined) {
candidates = candidates.filter(e => e.importance >= options.minImportance!);
}
}
// Score and rank using cached tokens
const scored = candidates
.map(entry => ({ entry, score: searchScore(entry, queryTokens) }))
.map(entry => {
const cachedTokens = this.memoryIndex.getTokens(entry.id);
return { entry, score: searchScore(entry, queryTokens, cachedTokens) };
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score);
const limit = options?.limit ?? 10;
const results = scored.slice(0, limit).map(item => item.entry);
// Cache the results
this.memoryIndex.setCached(query, options, results.map(r => r.id));
// Update access metadata
const now = new Date().toISOString();
for (const entry of results) {
@@ -196,16 +267,36 @@ export class MemoryManager {
this.persist();
}
this.memoryIndex.recordQueryTime(performance.now() - startTime);
return results;
}
// === Get All (for an agent) ===
// === Get All (for an agent) - Optimized with Index ===
async getAll(agentId: string, options?: { type?: MemoryType; limit?: number }): Promise<MemoryEntry[]> {
let results = this.entries.filter(e => e.agentId === agentId);
this.ensureIndexInitialized();
if (options?.type) {
results = results.filter(e => e.type === options.type);
// Use index to get candidates for this agent
const candidateIds = this.memoryIndex.getCandidates({
agentId,
type: options?.type,
});
let results: MemoryEntry[];
if (candidateIds) {
results = [];
for (const id of candidateIds) {
const idx = this.entryIndex.get(id);
if (idx !== undefined) {
results.push(this.entries[idx]);
}
}
} else {
// Fallback to linear scan
results = this.entries.filter(e => e.agentId === agentId);
if (options?.type) {
results = results.filter(e => e.type === options.type);
}
}
results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -217,17 +308,27 @@ export class MemoryManager {
return results;
}
// === Get by ID ===
// === Get by ID (O(1) with index) ===
async get(id: string): Promise<MemoryEntry | null> {
return this.entries.find(e => e.id === id) ?? null;
const idx = this.entryIndex.get(id);
return idx !== undefined ? this.entries[idx] ?? null : null;
}
// === Forget ===
async forget(id: string): Promise<void> {
this.entries = this.entries.filter(e => e.id !== id);
this.persist();
const idx = this.entryIndex.get(id);
if (idx !== undefined) {
this.removeEntryFromIndex(id);
this.entries.splice(idx, 1);
// Rebuild entry index since positions changed
this.entryIndex.clear();
this.entries.forEach((entry, i) => {
this.entryIndex.set(entry.id, i);
});
this.persist();
}
}
// === Prune (bulk cleanup) ===
@@ -240,6 +341,8 @@ export class MemoryManager {
const before = this.entries.length;
const now = Date.now();
const toRemove: string[] = [];
this.entries = this.entries.filter(entry => {
if (options.agentId && entry.agentId !== options.agentId) return true; // keep other agents
@@ -248,10 +351,24 @@ export class MemoryManager {
const tooLow = options.minImportance !== undefined && entry.importance < options.minImportance;
// Only prune if both conditions met (old AND low importance)
if (tooOld && tooLow) return false;
if (tooOld && tooLow) {
toRemove.push(entry.id);
return false;
}
return true;
});
// Remove from index
for (const id of toRemove) {
this.removeEntryFromIndex(id);
}
// Rebuild entry index
this.entryIndex.clear();
this.entries.forEach((entry, i) => {
this.entryIndex.set(entry.id, i);
});
const pruned = before - this.entries.length;
if (pruned > 0) {
this.persist();

View File

@@ -0,0 +1,373 @@
/**
* ZCLAW Error Handling Utilities
*
* Centralized error reporting, notification, and tracking system.
*/
import { v4 as uuidv4 } from 'uuid';
import {
AppError,
classifyError,
ErrorCategory,
ErrorSeverity,
} from './error-types';
// === Error Store ===
interface StoredError extends AppError {
dismissed: boolean;
reported: boolean;
}
interface ErrorStore {
errors: StoredError[];
addError: (error: AppError) => void;
dismissError: (id: string) => void;
dismissAll: () => void;
markReported: (id: string) => void;
getUndismissedErrors: () => StoredError[];
getErrorCount: () => number;
getErrorsByCategory: (category: ErrorCategory) => StoredError[];
getErrorsBySeverity: (severity: ErrorSeverity) => StoredError[];
}
// === Global Error Store ===
let errorStore: ErrorStore = {
errors: [],
addError: () => {},
dismissError: () => {},
dismissAll: () => {},
markReported: () => {},
getUndismissedErrors: () => [],
getErrorCount: () => 0,
getErrorsByCategory: () => [],
getErrorsBySeverity: () => [],
};
// === Initialize Store ===
function initErrorStore(): void {
errorStore = {
errors: [],
addError: (error: AppError) => {
errorStore.errors = [error, ...errorStore.errors];
// Notify listeners
notifyErrorListeners(error);
},
dismissError: (id: string) => void {
const error = errorStore.errors.find(e => e.id === id);
if (error) {
errorStore.errors = errorStore.errors.map(e =>
e.id === id ? { ...e, dismissed: true } : e
);
}
},
dismissAll: () => void {
errorStore.errors = errorStore.errors.map(e => ({ ...e, dismissed: true }));
},
markReported: (id: string) => void {
const error = errorStore.errors.find(e => e.id === id);
if (error) {
errorStore.errors = errorStore.errors.map(e =>
e.id === id ? { ...e, reported: true } : e
);
}
},
getUndismissedErrors: () => StoredError[] => {
return errorStore.errors.filter(e => !e.dismissed);
},
getErrorCount: () => number => {
return errorStore.errors.filter(e => !e.dismissed).length;
},
getErrorsByCategory: (category: ErrorCategory) => StoredError[] => {
return errorStore.errors.filter(e => e.category === category && !e.dismissed);
},
getErrorsBySeverity: (severity: ErrorSeverity) => StoredError[] => {
return errorStore.errors.filter(e => e.severity === severity && !e.dismissed);
},
};
}
// === Error Listeners ===
type ErrorListener = (error: AppError) => void;
const errorListeners: Set<ErrorListener> = new Set();
function addErrorListener(listener: ErrorListener): () => void {
errorListeners.add(listener);
return () => errorListeners.delete(listener);
}
function notifyErrorListeners(error: AppError): void {
errorListeners.forEach(listener => {
try {
listener(error);
} catch (e) {
console.error('[ErrorHandling] Listener error:', e);
}
});
}
// Initialize on first import
initErrorStore();
// === Public API ===
/**
* Report an error to the centralized error handling system.
*/
export function reportError(
error: unknown,
context?: {
componentStack?: string;
errorName?: string;
errorMessage?: string;
}
): AppError {
const appError = classifyError(error);
// Add context information if provided
if (context) {
const technicalDetails = [
context.componentStack && `Component Stack:\n${context.componentStack}`,
context.errorName && `Error Name: ${context.errorName}`,
context.errorMessage && `Error Message: ${context.errorMessage}`,
].filter(Boolean).join('\n\n');
if (technicalDetails) {
(appError as { technicalDetails?: string }).technicalDetails = technicalDetails;
}
}
errorStore.addError(appError);
// Log to console in development
if (import.meta.env.DEV) {
console.error('[ErrorHandling] Error reported:', {
id: appError.id,
category: appError.category,
severity: appError.severity,
title: appError.title,
message: appError.message,
});
}
return appError;
}
/**
* Report an error from an API response.
*/
export function reportApiError(
response: Response,
endpoint: string,
method: string = 'GET'
): AppError {
const status = response.status;
let category: ErrorCategory = 'server';
let severity: ErrorSeverity = 'medium';
let title = 'API Error';
let message = `Request to ${endpoint} failed with status ${status}`;
let recoverySteps: { description: string }[] = [];
if (status === 401) {
category = 'auth';
severity = 'high';
title = 'Authentication Required';
message = 'Your session has expired. Please authenticate again.';
recoverySteps = [
{ description: 'Click "Reconnect" to authenticate' },
{ description: 'Check your API key in settings' },
];
} else if (status === 403) {
category = 'permission';
severity = 'medium';
title = 'Permission Denied';
message = 'You do not have permission to perform this action.';
recoverySteps = [
{ description: 'Contact your administrator for access' },
{ description: 'Check your RBAC configuration' },
];
} else if (status === 404) {
category = 'client';
severity = 'low';
title = 'Not Found';
message = `The requested resource was not found: ${endpoint}`;
recoverySteps = [
{ description: 'Verify the resource exists' },
{ description: 'Check the URL is correct' },
];
} else if (status === 422) {
category = 'validation';
severity = 'low';
title = 'Validation Error';
message = 'The request data is invalid.';
recoverySteps = [
{ description: 'Check your input data format' },
{ description: 'Verify required fields are provided' },
];
} else if (status === 429) {
category = 'client';
severity = 'medium';
title = 'Rate Limited';
message = 'Too many requests. Please wait before trying again.';
recoverySteps = [
{ description: 'Wait a moment before retrying' },
{ description: 'Reduce request frequency' },
];
} else if (status >= 500) {
category = 'server';
severity = 'high';
title = 'Server Error';
message = 'The server encountered an error processing your request.';
recoverySteps = [
{ description: 'Try again in a few moments' },
{ description: 'Contact support if the problem persists' },
];
}
const appError: AppError = {
id: uuidv4(),
category,
severity,
title,
message,
technicalDetails: `${method} ${endpoint}\nStatus: ${status}\nResponse: ${response.statusText}`,
recoverable: status !== 500 || status < 400,
recoverySteps,
timestamp: new Date(),
originalError: response,
};
errorStore.addError(appError);
return appError;
}
/**
* Report a network error.
*/
export function reportNetworkError(
error: Error,
url?: string
): AppError {
return reportError(error, {
errorMessage: url ? `URL: ${url}\n${error.message}` : error.message,
});
}
/**
* Report a WebSocket error.
*/
export function reportWebSocketError(
event: CloseEvent | ErrorEvent,
url: string
): AppError {
const code = 'code' in event ? event.code : 0;
const reason = 'reason' in event ? event.reason : 'Unknown';
return reportError(
new Error(`WebSocket error: ${reason} (code: ${code})`),
{
errorMessage: `WebSocket URL: ${url}\nCode: ${code}\nReason: ${reason}`,
}
);
}
/**
* Dismiss an error by ID.
*/
export function dismissError(id: string): void {
errorStore.dismissError(id);
}
/**
* Dismiss all active errors.
*/
export function dismissAllErrors(): void {
errorStore.dismissAll();
}
/**
* Mark an error as reported.
*/
export function markErrorReported(id: string): void {
errorStore.markReported(id);
}
/**
* Get all active (non-dismissed) errors.
*/
export function getActiveErrors(): StoredError[] {
return errorStore.getUndismissedErrors();
}
/**
* Get the count of active errors.
*/
export function getActiveErrorCount(): number {
return errorStore.getErrorCount();
}
/**
* Get errors filtered by category.
*/
export function getErrorsByCategory(category: ErrorCategory): StoredError[] {
return errorStore.getErrorsByCategory(category);
}
/**
* Get errors filtered by severity.
*/
export function getErrorsBySeverity(severity: ErrorSeverity): StoredError[] {
return errorStore.getErrorsBySeverity(severity);
}
/**
* Subscribe to error events.
*/
export function subscribeToErrors(listener: ErrorListener): () => void {
return addErrorListener(listener);
}
/**
* Check if there are any critical errors.
*/
export function hasCriticalErrors(): boolean {
return errorStore.getErrorsBySeverity('critical').length > 0;
}
/**
* Check if there are any high severity errors.
*/
export function hasHighSeverityErrors(): boolean {
const highSeverity = ['high', 'critical'];
return errorStore.errors.some(e => highSeverity.includes(e.severity) && !e.dismissed);
}
// === Types ===
interface CloseEvent {
code?: number;
reason?: string;
wasClean?: boolean;
}
interface ErrorEvent {
code?: number;
reason?: string;
message?: string;
}
export interface StoredError extends AppError {
dismissed: boolean;
reported: boolean;
}

View File

@@ -0,0 +1,524 @@
/**
* ZCLAW Error Types and Utilities
*
* Provides a unified error classification system with recovery suggestions
* for user-friendly error handling.
*/
// === Error Categories ===
export type ErrorCategory =
| 'network' // Network connectivity issues
| 'auth' // Authentication and authorization failures
| 'permission' // RBAC permission denied
| 'validation' // Input validation errors
| 'timeout' // Request timeout
| 'server' // Server-side errors (5xx)
| 'client' // Client-side errors (4xx)
| 'config' // Configuration errors
| 'system'; // System/runtime errors
// === Error Severity ===
export type ErrorSeverity = 'low' | 'medium' | 'high' | 'critical';
// === App Error Interface ===
export interface AppError {
id: string;
category: ErrorCategory;
severity: ErrorSeverity;
title: string;
message: string;
technicalDetails?: string;
recoverable: boolean;
recoverySteps: RecoveryStep[];
timestamp: Date;
originalError?: unknown;
}
export interface RecoveryStep {
description: string;
action?: () => void | Promise<void>;
label?: string;
}
// === Error Detection Patterns ===
interface ErrorPattern {
patterns: (string | RegExp)[];
category: ErrorCategory;
severity: ErrorSeverity;
title: string;
messageTemplate: (match: string) => string;
recoverySteps: RecoveryStep[];
recoverable: boolean;
}
const ERROR_PATTERNS: ErrorPattern[] = [
// Network Errors
{
patterns: [
'Failed to fetch',
'NetworkError',
'ERR_NETWORK',
'ERR_CONNECTION_REFUSED',
'ERR_CONNECTION_RESET',
'ERR_INTERNET_DISCONNECTED',
'WebSocket connection failed',
'ECONNREFUSED',
],
category: 'network',
severity: 'high',
title: 'Network Connection Error',
messageTemplate: () => 'Unable to connect to the server. Please check your network connection.',
recoverySteps: [
{ description: 'Check your internet connection is active' },
{ description: 'Verify the server address is correct' },
{ description: 'Try again in a few moments' },
],
recoverable: true,
},
{
patterns: ['ERR_NAME_NOT_RESOLVED', 'DNS', 'ENOTFOUND'],
category: 'network',
severity: 'high',
title: 'DNS Resolution Failed',
messageTemplate: () => 'Could not resolve the server address. The server may be offline or the address is incorrect.',
recoverySteps: [
{ description: 'Verify the server URL is correct' },
{ description: 'Check if the server is running' },
{ description: 'Try using an IP address instead of hostname' },
],
recoverable: true,
},
// Authentication Errors
{
patterns: [
'401',
'Unauthorized',
'Invalid token',
'Token expired',
'Authentication failed',
'Not authenticated',
'JWT expired',
],
category: 'auth',
severity: 'high',
title: 'Authentication Failed',
messageTemplate: () => 'Your session has expired or is invalid. Please log in again.',
recoverySteps: [
{ description: 'Click "Reconnect" to authenticate again' },
{ description: 'Check your API key or credentials in settings' },
{ description: 'Verify your account is active' },
],
recoverable: true,
},
{
patterns: ['Invalid API key', 'API key expired', 'Invalid credentials'],
category: 'auth',
severity: 'high',
title: 'Invalid Credentials',
messageTemplate: () => 'The provided API key or credentials are invalid.',
recoverySteps: [
{ description: 'Check your API key in the settings' },
{ description: 'Generate a new API key from your provider dashboard' },
{ description: 'Ensure the key has not been revoked' },
],
recoverable: true,
},
// Permission Errors
{
patterns: [
'403',
'Forbidden',
'Permission denied',
'Access denied',
'Insufficient permissions',
'RBAC',
'Not authorized',
],
category: 'permission',
severity: 'medium',
title: 'Permission Denied',
messageTemplate: () => 'You do not have permission to perform this action.',
recoverySteps: [
{ description: 'Contact your administrator for access' },
{ description: 'Check your role has the required capabilities' },
{ description: 'Verify the resource exists and you have access' },
],
recoverable: false,
},
// Timeout Errors
{
patterns: [
'ETIMEDOUT',
'Timeout',
'Request timeout',
'timed out',
'Deadline exceeded',
],
category: 'timeout',
severity: 'medium',
title: 'Request Timeout',
messageTemplate: () => 'The request took too long to complete. The server may be overloaded.',
recoverySteps: [
{ description: 'Try again with a simpler request' },
{ description: 'Wait a moment and retry' },
{ description: 'Check server status and load' },
],
recoverable: true,
},
// Validation Errors
{
patterns: [
'400',
'Bad Request',
'Validation failed',
'Invalid input',
'Invalid parameter',
'Schema validation',
],
category: 'validation',
severity: 'low',
title: 'Invalid Input',
messageTemplate: (match) => `The request contains invalid data: ${match}`,
recoverySteps: [
{ description: 'Check your input for errors' },
{ description: 'Ensure all required fields are filled' },
{ description: 'Verify the format matches requirements' },
],
recoverable: true,
},
{
patterns: ['413', 'Payload too large', 'Request entity too large'],
category: 'validation',
severity: 'medium',
title: 'Request Too Large',
messageTemplate: () => 'The request exceeds the maximum allowed size.',
recoverySteps: [
{ description: 'Reduce the size of your input' },
{ description: 'Split large requests into smaller ones' },
{ description: 'Remove unnecessary attachments or data' },
],
recoverable: true,
},
// Server Errors
{
patterns: [
'500',
'Internal Server Error',
'InternalServerError',
'502',
'Bad Gateway',
'503',
'Service Unavailable',
'504',
'Gateway Timeout',
],
category: 'server',
severity: 'high',
title: 'Server Error',
messageTemplate: () => 'The server encountered an error and could not complete your request.',
recoverySteps: [
{ description: 'Wait a few moments and try again' },
{ description: 'Check the service status page' },
{ description: 'Contact support if the problem persists' },
],
recoverable: true,
},
// Rate Limiting
{
patterns: ['429', 'Too Many Requests', 'Rate limit', 'quota exceeded'],
category: 'client',
severity: 'medium',
title: 'Rate Limited',
messageTemplate: () => 'Too many requests. Please wait before trying again.',
recoverySteps: [
{ description: 'Wait a minute before sending more requests' },
{ description: 'Reduce request frequency' },
{ description: 'Check your usage quota' },
],
recoverable: true,
},
// Configuration Errors
{
patterns: [
'Config not found',
'Invalid configuration',
'TOML parse error',
'Missing configuration',
],
category: 'config',
severity: 'medium',
title: 'Configuration Error',
messageTemplate: () => 'There is a problem with the application configuration.',
recoverySteps: [
{ description: 'Check your configuration file syntax' },
{ description: 'Verify all required settings are present' },
{ description: 'Reset to default configuration if needed' },
],
recoverable: true,
},
// WebSocket Errors
{
patterns: [
'WebSocket',
'socket closed',
'socket hang up',
'Connection closed',
'Not connected',
],
category: 'network',
severity: 'high',
title: 'Connection Lost',
messageTemplate: () => 'The connection to the server was lost. Attempting to reconnect...',
recoverySteps: [
{ description: 'Check your network connection' },
{ description: 'Click "Reconnect" to establish a new connection' },
{ description: 'Verify the server is running' },
],
recoverable: true,
},
// Hand/Workflow Errors
{
patterns: ['Hand failed', 'Hand error', 'needs_approval', 'approval required'],
category: 'permission',
severity: 'medium',
title: 'Hand Execution Failed',
messageTemplate: () => 'The autonomous capability (Hand) could not execute.',
recoverySteps: [
{ description: 'Check if the Hand requires approval' },
{ description: 'Verify you have the necessary permissions' },
{ description: 'Review the Hand configuration' },
],
recoverable: true,
},
{
patterns: ['Workflow failed', 'Workflow error', 'step failed'],
category: 'server',
severity: 'medium',
title: 'Workflow Execution Failed',
messageTemplate: () => 'The workflow encountered an error during execution.',
recoverySteps: [
{ description: 'Review the workflow steps for errors' },
{ description: 'Check the workflow configuration' },
{ description: 'Try running individual steps manually' },
],
recoverable: true,
},
];
// === Error Classification Function ===
function matchPattern(error: unknown): { pattern: ErrorPattern; match: string } | null {
const errorString = typeof error === 'string'
? error
: error instanceof Error
? `${error.message} ${error.name} ${error.stack || ''}`
: String(error);
for (const pattern of ERROR_PATTERNS) {
for (const p of pattern.patterns) {
const regex = p instanceof RegExp ? p : new RegExp(p, 'i');
const match = errorString.match(regex);
if (match) {
return { pattern, match: match[0] };
}
}
}
return null;
}
/**
* Classify an error and create an AppError with recovery suggestions.
*/
export function classifyError(error: unknown): AppError {
const matched = matchPattern(error);
if (matched) {
const { pattern, match } = matched;
return {
id: `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
category: pattern.category,
severity: pattern.severity,
title: pattern.title,
message: pattern.messageTemplate(match),
technicalDetails: error instanceof Error
? `${error.name}: ${error.message}\n${error.stack || ''}`
: String(error),
recoverable: pattern.recoverable,
recoverySteps: pattern.recoverySteps,
timestamp: new Date(),
originalError: error,
};
}
// Unknown error - return generic error
return {
id: `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
category: 'system',
severity: 'medium',
title: 'An Error Occurred',
message: error instanceof Error ? error.message : 'An unexpected error occurred.',
technicalDetails: error instanceof Error
? `${error.name}: ${error.message}\n${error.stack || ''}`
: String(error),
recoverable: true,
recoverySteps: [
{ description: 'Try the operation again' },
{ description: 'Refresh the page if the problem persists' },
{ description: 'Contact support with the error details' },
],
timestamp: new Date(),
originalError: error,
};
}
// === Error Category Icons and Colors ===
export interface ErrorCategoryStyle {
icon: string;
color: string;
bgColor: string;
borderColor: string;
}
export const ERROR_CATEGORY_STYLES: Record<ErrorCategory, ErrorCategoryStyle> = {
network: {
icon: 'Wifi',
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
borderColor: 'border-orange-200 dark:border-orange-800',
},
auth: {
icon: 'Key',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-200 dark:border-red-800',
},
permission: {
icon: 'Shield',
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
borderColor: 'border-purple-200 dark:border-purple-800',
},
validation: {
icon: 'AlertCircle',
color: 'text-yellow-600 dark:text-yellow-400',
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
borderColor: 'border-yellow-200 dark:border-yellow-800',
},
timeout: {
icon: 'Clock',
color: 'text-amber-600 dark:text-amber-400',
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
borderColor: 'border-amber-200 dark:border-amber-800',
},
server: {
icon: 'Server',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-200 dark:border-red-800',
},
client: {
icon: 'User',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
borderColor: 'border-blue-200 dark:border-blue-800',
},
config: {
icon: 'Settings',
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
borderColor: 'border-gray-200 dark:border-gray-800',
},
system: {
icon: 'AlertTriangle',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-200 dark:border-red-800',
},
};
// === Error Severity Styles ===
export const ERROR_SEVERITY_STYLES: Record<ErrorSeverity, { badge: string; priority: number }> = {
low: {
badge: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
priority: 1,
},
medium: {
badge: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
priority: 2,
},
high: {
badge: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
priority: 3,
},
critical: {
badge: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
priority: 4,
},
};
// === Helper Functions ===
/**
* Format an error for display in a toast notification.
*/
export function formatErrorForToast(error: AppError): { title: string; message: string } {
return {
title: error.title,
message: error.message.length > 100
? `${error.message.slice(0, 100)}...`
: error.message,
};
}
/**
* Check if an error is recoverable and suggest primary action.
*/
export function getPrimaryRecoveryAction(error: AppError): RecoveryStep | undefined {
if (!error.recoverable || error.recoverySteps.length === 0) {
return undefined;
}
return error.recoverySteps[0];
}
/**
* Create a copy of the error details for clipboard.
*/
export function formatErrorForClipboard(error: AppError): string {
const lines = [
`Error ID: ${error.id}`,
`Category: ${error.category}`,
`Severity: ${error.severity}`,
`Time: ${error.timestamp.toISOString()}`,
'',
`Title: ${error.title}`,
`Message: ${error.message}`,
];
if (error.technicalDetails) {
lines.push('', 'Technical Details:', error.technicalDetails);
}
if (error.recoverySteps.length > 0) {
lines.push('', 'Recovery Steps:');
error.recoverySteps.forEach((step, i) => {
lines.push(`${i + 1}. ${step.description}`);
});
}
return lines.join('\n');
}

View File

@@ -0,0 +1,443 @@
/**
* Memory Index - High-performance indexing for agent memory retrieval
*
* Implements inverted index + LRU cache for sub-20ms retrieval on 1000+ memories.
*
* Performance targets:
* - Retrieval latency: <20ms (vs ~50ms with linear scan)
* - 1000 memories: smooth operation
* - Memory overhead: ~30% additional for indexes
*
* Reference: Task "Optimize ZCLAW Agent Memory Retrieval Performance"
*/
import type { MemoryEntry, MemoryType } from './agent-memory';
// === Types ===
export interface IndexStats {
totalEntries: number;
keywordCount: number;
cacheHitRate: number;
cacheSize: number;
avgQueryTime: number;
}
interface CacheEntry {
results: string[]; // memory IDs
timestamp: number;
}
// === Tokenization (shared with agent-memory.ts) ===
export function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf]+/g, ' ')
.split(/\s+/)
.filter(t => t.length > 0);
}
// === LRU Cache Implementation ===
class LRUCache<K, V> {
private cache: Map<K, V>;
private maxSize: number;
constructor(maxSize: number) {
this.cache = new Map();
this.maxSize = maxSize;
}
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, value);
}
clear(): void {
this.cache.clear();
}
get size(): number {
return this.cache.size;
}
}
// === Memory Index Implementation ===
export class MemoryIndex {
// Inverted indexes
private keywordIndex: Map<string, Set<string>> = new Map(); // keyword -> memoryIds
private typeIndex: Map<MemoryType, Set<string>> = new Map(); // type -> memoryIds
private agentIndex: Map<string, Set<string>> = new Map(); // agentId -> memoryIds
private tagIndex: Map<string, Set<string>> = new Map(); // tag -> memoryIds
// Pre-tokenized content cache
private tokenCache: Map<string, string[]> = new Map(); // memoryId -> tokens
// Query result cache
private queryCache: LRUCache<string, CacheEntry>;
// Statistics
private cacheHits = 0;
private cacheMisses = 0;
private queryTimes: number[] = [];
constructor(cacheSize = 100) {
this.queryCache = new LRUCache(cacheSize);
}
// === Index Building ===
/**
* Build or update index for a memory entry.
* Call this when adding or updating a memory.
*/
index(entry: MemoryEntry): void {
const { id, agentId, type, tags, content } = entry;
// Index by agent
if (!this.agentIndex.has(agentId)) {
this.agentIndex.set(agentId, new Set());
}
this.agentIndex.get(agentId)!.add(id);
// Index by type
if (!this.typeIndex.has(type)) {
this.typeIndex.set(type, new Set());
}
this.typeIndex.get(type)!.add(id);
// Index by tags
for (const tag of tags) {
const normalizedTag = tag.toLowerCase();
if (!this.tagIndex.has(normalizedTag)) {
this.tagIndex.set(normalizedTag, new Set());
}
this.tagIndex.get(normalizedTag)!.add(id);
}
// Index by content keywords
const tokens = tokenize(content);
this.tokenCache.set(id, tokens);
for (const token of tokens) {
if (!this.keywordIndex.has(token)) {
this.keywordIndex.set(token, new Set());
}
this.keywordIndex.get(token)!.add(id);
}
// Invalidate query cache on index change
this.queryCache.clear();
}
/**
* Remove a memory from all indexes.
*/
remove(memoryId: string): void {
// Remove from agent index
for (const [agentId, ids] of this.agentIndex) {
ids.delete(memoryId);
if (ids.size === 0) {
this.agentIndex.delete(agentId);
}
}
// Remove from type index
for (const [type, ids] of this.typeIndex) {
ids.delete(memoryId);
if (ids.size === 0) {
this.typeIndex.delete(type);
}
}
// Remove from tag index
for (const [tag, ids] of this.tagIndex) {
ids.delete(memoryId);
if (ids.size === 0) {
this.tagIndex.delete(tag);
}
}
// Remove from keyword index
for (const [keyword, ids] of this.keywordIndex) {
ids.delete(memoryId);
if (ids.size === 0) {
this.keywordIndex.delete(keyword);
}
}
// Remove token cache
this.tokenCache.delete(memoryId);
// Invalidate query cache
this.queryCache.clear();
}
/**
* Rebuild all indexes from scratch.
* Use after bulk updates or data corruption.
*/
rebuild(entries: MemoryEntry[]): void {
this.clear();
for (const entry of entries) {
this.index(entry);
}
}
/**
* Clear all indexes.
*/
clear(): void {
this.keywordIndex.clear();
this.typeIndex.clear();
this.agentIndex.clear();
this.tagIndex.clear();
this.tokenCache.clear();
this.queryCache.clear();
this.cacheHits = 0;
this.cacheMisses = 0;
this.queryTimes = [];
}
// === Fast Filtering ===
/**
* Get candidate memory IDs based on filter options.
* Uses indexes for O(1) lookups instead of O(n) scans.
*/
getCandidates(options: {
agentId?: string;
type?: MemoryType;
types?: MemoryType[];
tags?: string[];
}): Set<string> | null {
const candidateSets: Set<string>[] = [];
// Filter by agent
if (options.agentId) {
const agentSet = this.agentIndex.get(options.agentId);
if (!agentSet) return new Set(); // Agent has no memories
candidateSets.push(agentSet);
}
// Filter by single type
if (options.type) {
const typeSet = this.typeIndex.get(options.type);
if (!typeSet) return new Set(); // No memories of this type
candidateSets.push(typeSet);
}
// Filter by multiple types
if (options.types && options.types.length > 0) {
const typeUnion = new Set<string>();
for (const t of options.types) {
const typeSet = this.typeIndex.get(t);
if (typeSet) {
for (const id of typeSet) {
typeUnion.add(id);
}
}
}
if (typeUnion.size === 0) return new Set();
candidateSets.push(typeUnion);
}
// Filter by tags (OR logic - match any tag)
if (options.tags && options.tags.length > 0) {
const tagUnion = new Set<string>();
for (const tag of options.tags) {
const normalizedTag = tag.toLowerCase();
const tagSet = this.tagIndex.get(normalizedTag);
if (tagSet) {
for (const id of tagSet) {
tagUnion.add(id);
}
}
}
if (tagUnion.size === 0) return new Set();
candidateSets.push(tagUnion);
}
// Intersect all candidate sets
if (candidateSets.length === 0) {
return null; // No filters applied, return null to indicate "all"
}
// Start with smallest set for efficiency
candidateSets.sort((a, b) => a.size - b.size);
let result = new Set(candidateSets[0]);
for (let i = 1; i < candidateSets.length; i++) {
const nextSet = candidateSets[i];
result = new Set([...result].filter(id => nextSet.has(id)));
if (result.size === 0) break;
}
return result;
}
// === Keyword Search ===
/**
* Get memory IDs that contain any of the query keywords.
* Returns a map of memoryId -> match count for ranking.
*/
searchKeywords(queryTokens: string[]): Map<string, number> {
const matchCounts = new Map<string, number>();
for (const token of queryTokens) {
const matchingIds = this.keywordIndex.get(token);
if (matchingIds) {
for (const id of matchingIds) {
matchCounts.set(id, (matchCounts.get(id) ?? 0) + 1);
}
}
// Also check for partial matches (token is substring of indexed keyword)
for (const [keyword, ids] of this.keywordIndex) {
if (keyword.includes(token) || token.includes(keyword)) {
for (const id of ids) {
matchCounts.set(id, (matchCounts.get(id) ?? 0) + 1);
}
}
}
}
return matchCounts;
}
/**
* Get pre-tokenized content for a memory.
*/
getTokens(memoryId: string): string[] | undefined {
return this.tokenCache.get(memoryId);
}
// === Query Cache ===
/**
* Generate cache key from query and options.
*/
private getCacheKey(query: string, options?: Record<string, unknown>): string {
const opts = options ?? {};
return `${query}|${opts.agentId ?? ''}|${opts.type ?? ''}|${(opts.types as string[])?.join(',') ?? ''}|${(opts.tags as string[])?.join(',') ?? ''}|${opts.minImportance ?? ''}|${opts.limit ?? ''}`;
}
/**
* Get cached query results.
*/
getCached(query: string, options?: Record<string, unknown>): string[] | null {
const key = this.getCacheKey(query, options);
const cached = this.queryCache.get(key);
if (cached) {
this.cacheHits++;
return cached.results;
}
this.cacheMisses++;
return null;
}
/**
* Cache query results.
*/
setCached(query: string, options: Record<string, unknown> | undefined, results: string[]): void {
const key = this.getCacheKey(query, options);
this.queryCache.set(key, {
results,
timestamp: Date.now(),
});
}
// === Statistics ===
/**
* Record query time for statistics.
*/
recordQueryTime(timeMs: number): void {
this.queryTimes.push(timeMs);
// Keep last 100 query times
if (this.queryTimes.length > 100) {
this.queryTimes.shift();
}
}
/**
* Get index statistics.
*/
getStats(): IndexStats {
const avgQueryTime = this.queryTimes.length > 0
? this.queryTimes.reduce((a, b) => a + b, 0) / this.queryTimes.length
: 0;
const totalRequests = this.cacheHits + this.cacheMisses;
return {
totalEntries: this.tokenCache.size,
keywordCount: this.keywordIndex.size,
cacheHitRate: totalRequests > 0 ? this.cacheHits / totalRequests : 0,
cacheSize: this.queryCache.size,
avgQueryTime,
};
}
/**
* Get index memory usage estimate.
*/
getMemoryUsage(): { estimated: number; breakdown: Record<string, number> } {
let keywordIndexSize = 0;
for (const [keyword, ids] of this.keywordIndex) {
keywordIndexSize += keyword.length * 2 + ids.size * 50; // rough estimate
}
return {
estimated:
keywordIndexSize +
this.typeIndex.size * 100 +
this.agentIndex.size * 100 +
this.tagIndex.size * 100 +
this.tokenCache.size * 200,
breakdown: {
keywordIndex: keywordIndexSize,
typeIndex: this.typeIndex.size * 100,
agentIndex: this.agentIndex.size * 100,
tagIndex: this.tagIndex.size * 100,
tokenCache: this.tokenCache.size * 200,
},
};
}
}
// === Singleton ===
let _instance: MemoryIndex | null = null;
export function getMemoryIndex(): MemoryIndex {
if (!_instance) {
_instance = new MemoryIndex();
}
return _instance;
}
export function resetMemoryIndex(): void {
_instance = null;
}

View File

@@ -0,0 +1,656 @@
/**
* Session Persistence - Automatic session data persistence for L4 self-evolution
*
* Provides automatic persistence of conversation sessions:
* - Periodic auto-save of session state
* - Memory extraction at session end
* - Context compaction for long sessions
* - Session history and recovery
*
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.4
*/
import { getVikingClient, type VikingHttpClient } from './viking-client';
import { getMemoryManager, type MemoryType } from './agent-memory';
import { getMemoryExtractor } from './memory-extractor';
import { canAutoExecute, executeWithAutonomy } from './autonomy-manager';
// === Types ===
export interface SessionMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface SessionState {
id: string;
agentId: string;
startedAt: string;
lastActivityAt: string;
messageCount: number;
status: 'active' | 'paused' | 'ended';
messages: SessionMessage[];
metadata: {
model?: string;
workspaceId?: string;
conversationId?: string;
[key: string]: unknown;
};
}
export interface SessionPersistenceConfig {
enabled: boolean;
autoSaveIntervalMs: number; // Auto-save interval (default: 60s)
maxMessagesBeforeCompact: number; // Trigger compaction at this count
extractMemoriesOnEnd: boolean; // Extract memories when session ends
persistToViking: boolean; // Use OpenViking for persistence
fallbackToLocal: boolean; // Fall back to localStorage
maxSessionHistory: number; // Max sessions to keep in history
sessionTimeoutMs: number; // Session timeout (default: 30min)
}
export interface SessionSummary {
id: string;
agentId: string;
startedAt: string;
endedAt: string;
messageCount: number;
topicsDiscussed: string[];
memoriesExtracted: number;
compacted: boolean;
}
export interface PersistenceResult {
saved: boolean;
sessionId: string;
messageCount: number;
extractedMemories: number;
compacted: boolean;
error?: string;
}
// === Default Config ===
export const DEFAULT_SESSION_CONFIG: SessionPersistenceConfig = {
enabled: true,
autoSaveIntervalMs: 60000, // 1 minute
maxMessagesBeforeCompact: 100, // Compact after 100 messages
extractMemoriesOnEnd: true,
persistToViking: true,
fallbackToLocal: true,
maxSessionHistory: 50,
sessionTimeoutMs: 1800000, // 30 minutes
};
// === Storage Keys ===
const SESSION_STORAGE_KEY = 'zclaw-sessions';
const CURRENT_SESSION_KEY = 'zclaw-current-session';
// === Session Persistence Service ===
export class SessionPersistenceService {
private config: SessionPersistenceConfig;
private currentSession: SessionState | null = null;
private vikingClient: VikingHttpClient | null = null;
private autoSaveTimer: ReturnType<typeof setInterval> | null = null;
private sessionHistory: SessionSummary[] = [];
constructor(config?: Partial<SessionPersistenceConfig>) {
this.config = { ...DEFAULT_SESSION_CONFIG, ...config };
this.loadSessionHistory();
this.initializeVikingClient();
}
private async initializeVikingClient(): Promise<void> {
try {
this.vikingClient = getVikingClient();
} catch (error) {
console.warn('[SessionPersistence] Viking client initialization failed:', error);
}
}
// === Session Lifecycle ===
/**
* Start a new session.
*/
startSession(agentId: string, metadata?: Record<string, unknown>): SessionState {
// End any existing session first
if (this.currentSession && this.currentSession.status === 'active') {
this.endSession();
}
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
this.currentSession = {
id: sessionId,
agentId,
startedAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
messageCount: 0,
status: 'active',
messages: [],
metadata: metadata || {},
};
this.saveCurrentSession();
this.startAutoSave();
console.log(`[SessionPersistence] Started session: ${sessionId}`);
return this.currentSession;
}
/**
* Add a message to the current session.
*/
addMessage(message: Omit<SessionMessage, 'id' | 'timestamp'>): SessionMessage | null {
if (!this.currentSession || this.currentSession.status !== 'active') {
console.warn('[SessionPersistence] No active session');
return null;
}
const fullMessage: SessionMessage = {
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
...message,
timestamp: new Date().toISOString(),
};
this.currentSession.messages.push(fullMessage);
this.currentSession.messageCount++;
this.currentSession.lastActivityAt = fullMessage.timestamp;
// Check if compaction is needed
if (this.currentSession.messageCount >= this.config.maxMessagesBeforeCompact) {
this.compactSession();
}
return fullMessage;
}
/**
* Pause the current session.
*/
pauseSession(): void {
if (!this.currentSession) return;
this.currentSession.status = 'paused';
this.stopAutoSave();
this.saveCurrentSession();
console.log(`[SessionPersistence] Paused session: ${this.currentSession.id}`);
}
/**
* Resume a paused session.
*/
resumeSession(): SessionState | null {
if (!this.currentSession || this.currentSession.status !== 'paused') {
return this.currentSession;
}
this.currentSession.status = 'active';
this.currentSession.lastActivityAt = new Date().toISOString();
this.startAutoSave();
this.saveCurrentSession();
console.log(`[SessionPersistence] Resumed session: ${this.currentSession.id}`);
return this.currentSession;
}
/**
* End the current session and extract memories.
*/
async endSession(): Promise<PersistenceResult> {
if (!this.currentSession) {
return {
saved: false,
sessionId: '',
messageCount: 0,
extractedMemories: 0,
compacted: false,
error: 'No active session',
};
}
const session = this.currentSession;
session.status = 'ended';
this.stopAutoSave();
let extractedMemories = 0;
let compacted = false;
try {
// Extract memories from the session
if (this.config.extractMemoriesOnEnd && session.messageCount >= 4) {
extractedMemories = await this.extractMemories(session);
}
// Persist to OpenViking if available
if (this.config.persistToViking && this.vikingClient) {
await this.persistToViking(session);
}
// Save to local storage
this.saveToLocalStorage(session);
// Add to history
this.addToHistory(session, extractedMemories, compacted);
console.log(`[SessionPersistence] Ended session: ${session.id}, extracted ${extractedMemories} memories`);
return {
saved: true,
sessionId: session.id,
messageCount: session.messageCount,
extractedMemories,
compacted,
};
} catch (error) {
console.error('[SessionPersistence] Error ending session:', error);
return {
saved: false,
sessionId: session.id,
messageCount: session.messageCount,
extractedMemories: 0,
compacted: false,
error: String(error),
};
} finally {
this.clearCurrentSession();
}
}
// === Memory Extraction ===
private async extractMemories(session: SessionState): Promise<number> {
const extractor = getMemoryExtractor();
// Check if we can auto-extract
const { canProceed } = canAutoExecute('memory_save', 5);
if (!canProceed) {
console.log('[SessionPersistence] Memory extraction requires approval');
return 0;
}
try {
const messages = session.messages.map(m => ({
role: m.role,
content: m.content,
}));
const result = await extractor.extractFromConversation(
messages,
session.agentId,
session.id
);
return result.saved;
} catch (error) {
console.error('[SessionPersistence] Memory extraction failed:', error);
return 0;
}
}
// === Session Compaction ===
private async compactSession(): Promise<void> {
if (!this.currentSession || !this.vikingClient) return;
try {
const messages = this.currentSession.messages.map(m => ({
role: m.role,
content: m.content,
}));
// Use OpenViking to compact the session
const summary = await this.vikingClient.compactSession(messages);
// Keep recent messages, replace older ones with summary
const recentMessages = this.currentSession.messages.slice(-20);
// Create a summary message
const summaryMessage: SessionMessage = {
id: `summary_${Date.now()}`,
role: 'system',
content: `[会话摘要]\n${summary}`,
timestamp: new Date().toISOString(),
metadata: { type: 'compaction-summary' },
};
this.currentSession.messages = [summaryMessage, ...recentMessages];
this.currentSession.messageCount = this.currentSession.messages.length;
console.log(`[SessionPersistence] Compacted session: ${this.currentSession.id}`);
} catch (error) {
console.error('[SessionPersistence] Compaction failed:', error);
}
}
// === Persistence ===
private async persistToViking(session: SessionState): Promise<void> {
if (!this.vikingClient) return;
try {
const sessionContent = session.messages
.map(m => `[${m.role}]: ${m.content}`)
.join('\n\n');
await this.vikingClient.addResource(
`viking://sessions/${session.agentId}/${session.id}`,
sessionContent,
{
metadata: {
startedAt: session.startedAt,
endedAt: new Date().toISOString(),
messageCount: session.messageCount,
agentId: session.agentId,
},
wait: false,
}
);
} catch (error) {
console.error('[SessionPersistence] Viking persistence failed:', error);
if (!this.config.fallbackToLocal) {
throw error;
}
}
}
private saveToLocalStorage(session: SessionState): void {
try {
localStorage.setItem(
`${SESSION_STORAGE_KEY}/${session.id}`,
JSON.stringify(session)
);
} catch (error) {
console.error('[SessionPersistence] Local storage failed:', error);
}
}
private saveCurrentSession(): void {
if (!this.currentSession) return;
try {
localStorage.setItem(CURRENT_SESSION_KEY, JSON.stringify(this.currentSession));
} catch (error) {
console.error('[SessionPersistence] Failed to save current session:', error);
}
}
private loadCurrentSession(): SessionState | null {
try {
const raw = localStorage.getItem(CURRENT_SESSION_KEY);
if (raw) {
return JSON.parse(raw);
}
} catch (error) {
console.error('[SessionPersistence] Failed to load current session:', error);
}
return null;
}
private clearCurrentSession(): void {
this.currentSession = null;
try {
localStorage.removeItem(CURRENT_SESSION_KEY);
} catch {
// Ignore
}
}
// === Auto-save ===
private startAutoSave(): void {
if (this.autoSaveTimer) {
clearInterval(this.autoSaveTimer);
}
this.autoSaveTimer = setInterval(() => {
if (this.currentSession && this.currentSession.status === 'active') {
this.saveCurrentSession();
}
}, this.config.autoSaveIntervalMs);
}
private stopAutoSave(): void {
if (this.autoSaveTimer) {
clearInterval(this.autoSaveTimer);
this.autoSaveTimer = null;
}
}
// === Session History ===
private loadSessionHistory(): void {
try {
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
if (raw) {
this.sessionHistory = JSON.parse(raw);
}
} catch {
this.sessionHistory = [];
}
}
private saveSessionHistory(): void {
try {
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.sessionHistory));
} catch (error) {
console.error('[SessionPersistence] Failed to save session history:', error);
}
}
private addToHistory(session: SessionState, extractedMemories: number, compacted: boolean): void {
const summary: SessionSummary = {
id: session.id,
agentId: session.agentId,
startedAt: session.startedAt,
endedAt: new Date().toISOString(),
messageCount: session.messageCount,
topicsDiscussed: this.extractTopics(session),
memoriesExtracted: extractedMemories,
compacted,
};
this.sessionHistory.unshift(summary);
// Trim to max size
if (this.sessionHistory.length > this.config.maxSessionHistory) {
this.sessionHistory = this.sessionHistory.slice(0, this.config.maxSessionHistory);
}
this.saveSessionHistory();
}
private extractTopics(session: SessionState): string[] {
// Simple topic extraction from user messages
const userMessages = session.messages
.filter(m => m.role === 'user')
.map(m => m.content);
// Look for common patterns
const topics: string[] = [];
const patterns = [
/(?:帮我|请|能否)(.{2,10})/g,
/(?:问题|bug|错误|报错)(.{2,20})/g,
/(?:实现|添加|开发)(.{2,15})/g,
];
for (const msg of userMessages) {
for (const pattern of patterns) {
const matches = msg.matchAll(pattern);
for (const match of matches) {
if (match[1] && match[1].length > 2) {
topics.push(match[1].trim());
}
}
}
}
return [...new Set(topics)].slice(0, 10);
}
// === Public API ===
/**
* Get the current session.
*/
getCurrentSession(): SessionState | null {
return this.currentSession;
}
/**
* Get session history.
*/
getSessionHistory(limit: number = 20): SessionSummary[] {
return this.sessionHistory.slice(0, limit);
}
/**
* Restore a previous session.
*/
restoreSession(sessionId: string): SessionState | null {
try {
const raw = localStorage.getItem(`${SESSION_STORAGE_KEY}/${sessionId}`);
if (raw) {
const session = JSON.parse(raw) as SessionState;
session.status = 'active';
session.lastActivityAt = new Date().toISOString();
this.currentSession = session;
this.startAutoSave();
this.saveCurrentSession();
return session;
}
} catch (error) {
console.error('[SessionPersistence] Failed to restore session:', error);
}
return null;
}
/**
* Delete a session from history.
*/
deleteSession(sessionId: string): boolean {
try {
localStorage.removeItem(`${SESSION_STORAGE_KEY}/${sessionId}`);
this.sessionHistory = this.sessionHistory.filter(s => s.id !== sessionId);
this.saveSessionHistory();
return true;
} catch {
return false;
}
}
/**
* Get configuration.
*/
getConfig(): SessionPersistenceConfig {
return { ...this.config };
}
/**
* Update configuration.
*/
updateConfig(updates: Partial<SessionPersistenceConfig>): void {
this.config = { ...this.config, ...updates };
// Restart auto-save if interval changed
if (updates.autoSaveIntervalMs && this.currentSession?.status === 'active') {
this.stopAutoSave();
this.startAutoSave();
}
}
/**
* Check if session persistence is available.
*/
async isAvailable(): Promise<boolean> {
if (!this.config.enabled) return false;
if (this.config.persistToViking && this.vikingClient) {
return this.vikingClient.isAvailable();
}
return this.config.fallbackToLocal;
}
/**
* Recover from crash - restore last session if valid.
*/
recoverFromCrash(): SessionState | null {
const lastSession = this.loadCurrentSession();
if (!lastSession) return null;
// Check if session timed out
const lastActivity = new Date(lastSession.lastActivityAt).getTime();
const now = Date.now();
if (now - lastActivity > this.config.sessionTimeoutMs) {
console.log('[SessionPersistence] Last session timed out, not recovering');
this.clearCurrentSession();
return null;
}
// Recover the session
lastSession.status = 'active';
lastSession.lastActivityAt = new Date().toISOString();
this.currentSession = lastSession;
this.startAutoSave();
this.saveCurrentSession();
console.log(`[SessionPersistence] Recovered session: ${lastSession.id}`);
return lastSession;
}
}
// === Singleton ===
let _instance: SessionPersistenceService | null = null;
export function getSessionPersistence(config?: Partial<SessionPersistenceConfig>): SessionPersistenceService {
if (!_instance || config) {
_instance = new SessionPersistenceService(config);
}
return _instance;
}
export function resetSessionPersistence(): void {
_instance = null;
}
// === Helper Functions ===
/**
* Quick start a session.
*/
export function startSession(agentId: string, metadata?: Record<string, unknown>): SessionState {
return getSessionPersistence().startSession(agentId, metadata);
}
/**
* Quick add a message.
*/
export function addSessionMessage(message: Omit<SessionMessage, 'id' | 'timestamp'>): SessionMessage | null {
return getSessionPersistence().addMessage(message);
}
/**
* Quick end session.
*/
export async function endCurrentSession(): Promise<PersistenceResult> {
return getSessionPersistence().endSession();
}
/**
* Get current session.
*/
export function getCurrentSession(): SessionState | null {
return getSessionPersistence().getCurrentSession();
}

View File

@@ -0,0 +1,379 @@
/**
* Vector Memory - Semantic search wrapper for L4 self-evolution
*
* Provides vector-based semantic search over agent memories using OpenViking.
* This enables finding conceptually similar memories rather than just keyword matches.
*
* Key capabilities:
* - Semantic search: Find memories by meaning, not just keywords
* - Relevance scoring: Get similarity scores for search results
* - Context-aware: Search at different context levels (L0/L1/L2)
*
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.2
*/
import { getVikingClient, type VikingHttpClient } from './viking-client';
import { getMemoryManager, type MemoryEntry, type MemoryType } from './agent-memory';
// === Types ===
export interface VectorSearchResult {
memory: MemoryEntry;
score: number;
uri: string;
highlights?: string[];
}
export interface VectorSearchOptions {
topK?: number; // Number of results to return (default: 10)
minScore?: number; // Minimum relevance score (default: 0.5)
types?: MemoryType[]; // Filter by memory types
agentId?: string; // Filter by agent
level?: 'L0' | 'L1' | 'L2'; // Context level to search
}
export interface VectorEmbedding {
id: string;
vector: number[];
dimension: number;
model: string;
}
export interface VectorMemoryConfig {
enabled: boolean;
defaultTopK: number;
defaultMinScore: number;
defaultLevel: 'L0' | 'L1' | 'L2';
embeddingModel: string;
cacheEmbeddings: boolean;
}
// === Default Config ===
export const DEFAULT_VECTOR_CONFIG: VectorMemoryConfig = {
enabled: true,
defaultTopK: 10,
defaultMinScore: 0.3,
defaultLevel: 'L1',
embeddingModel: 'text-embedding-ada-002',
cacheEmbeddings: true,
};
// === Vector Memory Service ===
export class VectorMemoryService {
private config: VectorMemoryConfig;
private vikingClient: VikingHttpClient | null = null;
private embeddingCache: Map<string, VectorEmbedding> = new Map();
constructor(config?: Partial<VectorMemoryConfig>) {
this.config = { ...DEFAULT_VECTOR_CONFIG, ...config };
this.initializeClient();
}
private async initializeClient(): Promise<void> {
try {
this.vikingClient = getVikingClient();
} catch (error) {
console.warn('[VectorMemory] Failed to initialize Viking client:', error);
}
}
// === Semantic Search ===
/**
* Perform semantic search over memories.
* Uses OpenViking's built-in vector search capabilities.
*/
async semanticSearch(
query: string,
options?: VectorSearchOptions
): Promise<VectorSearchResult[]> {
if (!this.config.enabled) {
console.warn('[VectorMemory] Semantic search is disabled');
return [];
}
if (!this.vikingClient) {
await this.initializeClient();
if (!this.vikingClient) {
console.warn('[VectorMemory] Viking client not available');
return [];
}
}
try {
const results = await this.vikingClient.find(query, {
limit: options?.topK ?? this.config.defaultTopK,
minScore: options?.minScore ?? this.config.defaultMinScore,
level: options?.level ?? this.config.defaultLevel,
scope: options?.agentId ? `memories/${options.agentId}` : undefined,
});
// Convert FindResult to VectorSearchResult
const searchResults: VectorSearchResult[] = [];
for (const result of results) {
// Convert Viking result to MemoryEntry format
const memory: MemoryEntry = {
id: this.extractMemoryId(result.uri),
agentId: options?.agentId ?? 'unknown',
content: result.content,
type: this.inferMemoryType(result.uri),
importance: Math.round((1 - result.score) * 10), // Invert score to importance
createdAt: new Date().toISOString(),
source: 'auto',
tags: result.metadata?.tags ?? [],
};
searchResults.push({
memory,
score: result.score,
uri: result.uri,
highlights: result.highlights,
});
}
// Apply type filter if specified
if (options?.types && options.types.length > 0) {
return searchResults.filter(r => options.types!.includes(r.memory.type));
}
return searchResults;
} catch (error) {
console.error('[VectorMemory] Semantic search failed:', error);
return [];
}
}
/**
* Find similar memories to a given memory.
*/
async findSimilar(
memoryId: string,
options?: Omit<VectorSearchOptions, 'types'>
): Promise<VectorSearchResult[]> {
// Get the memory content first
const memoryManager = getMemoryManager();
const memories = memoryManager.getByAgent(options?.agentId ?? 'default');
const memory = memories.find(m => m.id === memoryId);
if (!memory) {
console.warn(`[VectorMemory] Memory not found: ${memoryId}`);
return [];
}
// Use the memory content as query for semantic search
const results = await this.semanticSearch(memory.content, {
...options,
topK: (options?.topK ?? 10) + 1, // +1 to account for the memory itself
});
// Filter out the original memory from results
return results.filter(r => r.memory.id !== memoryId);
}
/**
* Find memories related to a topic/concept.
*/
async findByConcept(
concept: string,
options?: VectorSearchOptions
): Promise<VectorSearchResult[]> {
return this.semanticSearch(concept, options);
}
/**
* Cluster memories by semantic similarity.
* Returns groups of related memories.
*/
async clusterMemories(
agentId: string,
clusterCount: number = 5
): Promise<VectorSearchResult[][]> {
const memoryManager = getMemoryManager();
const memories = memoryManager.getByAgent(agentId);
if (memories.length === 0) {
return [];
}
// Simple clustering: use each memory as a seed and find similar ones
const clusters: VectorSearchResult[][] = [];
const usedIds = new Set<string>();
for (const memory of memories) {
if (usedIds.has(memory.id)) continue;
const similar = await this.findSimilar(memory.id, { agentId, topK: clusterCount });
if (similar.length > 0) {
const cluster: VectorSearchResult[] = [
{ memory, score: 1.0, uri: `memory://${memory.id}` },
...similar.filter(r => !usedIds.has(r.memory.id)),
];
cluster.forEach(r => usedIds.add(r.memory.id));
clusters.push(cluster);
if (clusters.length >= clusterCount) break;
}
}
return clusters;
}
// === Embedding Operations ===
/**
* Get or compute embedding for a text.
* Note: OpenViking handles embeddings internally, this is for advanced use.
*/
async getEmbedding(text: string): Promise<VectorEmbedding | null> {
if (!this.config.enabled) return null;
// Check cache first
const cacheKey = this.hashText(text);
if (this.config.cacheEmbeddings && this.embeddingCache.has(cacheKey)) {
return this.embeddingCache.get(cacheKey)!;
}
// OpenViking handles embeddings internally via /api/find
// This method is provided for future extensibility
console.warn('[VectorMemory] Direct embedding computation not available - OpenViking handles this internally');
return null;
}
/**
* Compute similarity between two texts.
*/
async computeSimilarity(text1: string, text2: string): Promise<number> {
if (!this.config.enabled || !this.vikingClient) return 0;
try {
// Use OpenViking to find text1, then check if text2 is in results
const results = await this.vikingClient.find(text1, { limit: 20 });
// If we find text2 in results, return its score
for (const result of results) {
if (result.content.includes(text2) || text2.includes(result.content)) {
return result.score;
}
}
// Otherwise, return 0 (no similarity found)
return 0;
} catch {
return 0;
}
}
// === Utility Methods ===
/**
* Check if vector search is available.
*/
async isAvailable(): Promise<boolean> {
if (!this.config.enabled) return false;
if (!this.vikingClient) {
await this.initializeClient();
}
return this.vikingClient?.isAvailable() ?? false;
}
/**
* Get current configuration.
*/
getConfig(): VectorMemoryConfig {
return { ...this.config };
}
/**
* Update configuration.
*/
updateConfig(updates: Partial<VectorMemoryConfig>): void {
this.config = { ...this.config, ...updates };
}
/**
* Clear embedding cache.
*/
clearCache(): void {
this.embeddingCache.clear();
}
// === Private Helpers ===
private extractMemoryId(uri: string): string {
// Extract memory ID from Viking URI
// Format: memories/agent-id/memory-id or similar
const parts = uri.split('/');
return parts[parts.length - 1] || uri;
}
private inferMemoryType(uri: string): MemoryType {
// Infer memory type from URI or metadata
if (uri.includes('preference')) return 'preference';
if (uri.includes('fact')) return 'fact';
if (uri.includes('task')) return 'task';
if (uri.includes('lesson')) return 'lesson';
return 'fact'; // Default
}
private hashText(text: string): string {
// Simple hash for cache key
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString(16);
}
}
// === Singleton ===
let _instance: VectorMemoryService | null = null;
export function getVectorMemory(): VectorMemoryService {
if (!_instance) {
_instance = new VectorMemoryService();
}
return _instance;
}
export function resetVectorMemory(): void {
_instance = null;
}
// === Helper Functions ===
/**
* Quick semantic search helper.
*/
export async function semanticSearch(
query: string,
options?: VectorSearchOptions
): Promise<VectorSearchResult[]> {
return getVectorMemory().semanticSearch(query, options);
}
/**
* Find similar memories helper.
*/
export async function findSimilarMemories(
memoryId: string,
agentId?: string
): Promise<VectorSearchResult[]> {
return getVectorMemory().findSimilar(memoryId, { agentId });
}
/**
* Check if vector search is available.
*/
export async function isVectorSearchAvailable(): Promise<boolean> {
return getVectorMemory().isAvailable();
}

View File

@@ -327,3 +327,26 @@ export class VikingError extends Error {
this.name = 'VikingError';
}
}
// === Singleton ===
let _instance: VikingHttpClient | null = null;
/**
* Get the singleton VikingHttpClient instance.
* Uses default configuration (localhost:1933).
*/
export function getVikingClient(baseUrl?: string): VikingHttpClient {
if (!_instance) {
_instance = new VikingHttpClient(baseUrl);
}
return _instance;
}
/**
* Reset the singleton instance.
* Useful for testing or reconfiguration.
*/
export function resetVikingClient(): void {
_instance = null;
}

View File

@@ -0,0 +1,77 @@
/**
* * 技能市场类型定义
*
* * 用于管理技能浏览、搜索、安装/卸载等功能
*/
// 技能信息
export interface Skill {
/** 唯一标识 */
id: string;
/** 技能名称 */
name: string;
/** 技能描述 */
description: string;
/** 触发词列表 */
triggers: string[];
/** 能力列表 */
capabilities: string[];
/** 工具依赖 */
toolDeps?: string[];
/** 分类 */
category: string;
/** 作者 */
author?: string;
/** 版本 */
version?: string;
/** 标签 */
tags?: string[];
/** 安装状态 */
installed: boolean;
/** 评分 (1-5) */
rating?: number;
/** 评论数 */
reviewCount?: number;
/** 安装时间 */
installedAt?: string;
}
}
// 技能评论
export interface SkillReview {
/** 评论ID */
id: string;
/** 技能ID */
skillId: string;
/** 用户名 */
userName: string;
/** 评分 (1-5) */
rating: number;
/** 评论内容 */
comment: string;
/** 评论时间 */
createdAt: string;
}
}
// 技能市场状态
export interface SkillMarketState {
/** 所有技能 */
skills: Skill[];
/** 已安装技能 */
installedSkills: string[];
/** 搜索结果 */
searchResults: Skill[];
/** 当前选中的技能 */
selectedSkill: Skill | null;
/** 搜索关键词 */
searchQuery: string;
/** 分类过滤 */
categoryFilter: string;
/** 是否正在加载 */
isLoading: boolean;
/** 错误信息 */
error: string | null;
}