fix(安全): 修复HTML导出中的XSS漏洞并清理调试日志
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor(日志): 替换console.log为tracing日志系统 style(代码): 移除未使用的代码和依赖项 feat(测试): 添加端到端测试文档和CI工作流 docs(变更日志): 更新CHANGELOG.md记录0.1.0版本变更 perf(构建): 更新依赖版本并优化CI流程
This commit is contained in:
@@ -1,423 +0,0 @@
|
||||
/**
|
||||
* ActiveLearningPanel - 主动学习状态面板
|
||||
*
|
||||
* 展示学习事件、模式和系统建议。
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Brain,
|
||||
TrendingUp,
|
||||
Lightbulb,
|
||||
Check,
|
||||
X,
|
||||
Download,
|
||||
Clock,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { Button, EmptyState, Badge } from './ui';
|
||||
import { useActiveLearningStore } from '../store/activeLearningStore';
|
||||
import {
|
||||
type LearningEvent,
|
||||
type LearningSuggestion,
|
||||
type LearningEventType,
|
||||
} from '../types/active-learning';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
|
||||
// === Constants ===
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<LearningEventType, { label: string; color: string }> = {
|
||||
preference: { label: '偏好', color: 'text-amber-400' },
|
||||
correction: { label: '纠正', color: 'text-red-400' },
|
||||
context: { label: '上下文', color: 'text-purple-400' },
|
||||
feedback: { label: '反馈', color: 'text-blue-400' },
|
||||
behavior: { label: '行为', color: 'text-green-400' },
|
||||
implicit: { label: '隐式', color: 'text-gray-400' },
|
||||
};
|
||||
|
||||
const PATTERN_TYPE_LABELS: Record<string, { label: string; icon: string }> = {
|
||||
preference: { label: '偏好模式', icon: '🎯' },
|
||||
rule: { label: '规则模式', icon: '📋' },
|
||||
context: { label: '上下文模式', icon: '🔗' },
|
||||
behavior: { label: '行为模式', icon: '⚡' },
|
||||
};
|
||||
|
||||
// === Sub-Components ===
|
||||
|
||||
interface EventItemProps {
|
||||
event: LearningEvent;
|
||||
onAcknowledge: () => void;
|
||||
}
|
||||
|
||||
function EventItem({ event, onAcknowledge }: EventItemProps) {
|
||||
const typeInfo = EVENT_TYPE_LABELS[event.type];
|
||||
const timeAgo = getTimeAgo(event.timestamp);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className={`p-3 rounded-lg border ${
|
||||
event.acknowledged
|
||||
? 'bg-gray-50 dark:bg-gray-800 border-gray-100 dark:border-gray-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${typeInfo.color}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{timeAgo}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">{event.observation}</p>
|
||||
{event.inferredPreference && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">→ {event.inferredPreference}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!event.acknowledged && (
|
||||
<Button variant="ghost" size="sm" onClick={onAcknowledge}>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>置信度: {(event.confidence * 100).toFixed(0)}%</span>
|
||||
{event.appliedCount > 0 && (
|
||||
<span>• 应用 {event.appliedCount} 次</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SuggestionCardProps {
|
||||
suggestion: LearningSuggestion;
|
||||
onApply: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
function SuggestionCard({ suggestion, onApply, onDismiss }: SuggestionCardProps) {
|
||||
const daysLeft = Math.ceil(
|
||||
(suggestion.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="p-4 bg-gradient-to-r from-amber-50 to-transparent dark:from-amber-900/20 dark:to-transparent rounded-lg border border-amber-200 dark:border-amber-700/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-200">{suggestion.suggestion}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>置信度: {(suggestion.confidence * 100).toFixed(0)}%</span>
|
||||
{daysLeft > 0 && <span>• {daysLeft} 天后过期</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button variant="primary" size="sm" onClick={onApply}>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
应用
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onDismiss}>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
忽略
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
interface ActiveLearningPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps) {
|
||||
const { currentAgent } = useChatStore();
|
||||
const agentId = currentAgent?.id || 'default';
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'events' | 'patterns' | 'suggestions'>('suggestions');
|
||||
|
||||
const {
|
||||
events,
|
||||
config,
|
||||
acknowledgeEvent,
|
||||
getPatterns,
|
||||
getSuggestions,
|
||||
applySuggestion,
|
||||
dismissSuggestion,
|
||||
getStats,
|
||||
setConfig,
|
||||
exportLearningData,
|
||||
clearEvents,
|
||||
} = useActiveLearningStore();
|
||||
|
||||
const stats = getStats(agentId);
|
||||
const agentEvents = events.filter(e => e.agentId === agentId).slice(0, 20);
|
||||
const agentPatterns = getPatterns(agentId);
|
||||
const agentSuggestions = getSuggestions(agentId);
|
||||
|
||||
// 处理确认事件
|
||||
const handleAcknowledge = useCallback((eventId: string) => {
|
||||
acknowledgeEvent(eventId);
|
||||
}, [acknowledgeEvent]);
|
||||
|
||||
// 处理应用建议
|
||||
const handleApplySuggestion = useCallback((suggestionId: string) => {
|
||||
applySuggestion(suggestionId);
|
||||
}, [applySuggestion]);
|
||||
|
||||
// 处理忽略建议
|
||||
const handleDismissSuggestion = useCallback((suggestionId: string) => {
|
||||
dismissSuggestion(suggestionId);
|
||||
}, [dismissSuggestion]);
|
||||
|
||||
// 导出学习数据
|
||||
const handleExport = useCallback(async () => {
|
||||
const data = await exportLearningData(agentId);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `zclaw-learning-${agentId}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [agentId, exportLearningData]);
|
||||
|
||||
// 清除学习数据
|
||||
const handleClear = useCallback(() => {
|
||||
if (confirm('确定要清除所有学习数据吗?此操作不可撤销。')) {
|
||||
clearEvents(agentId);
|
||||
}
|
||||
}, [agentId, clearEvents]);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* 启用开关和导出 */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Brain className="w-4 h-4 text-blue-500" />
|
||||
<span>主动学习</span>
|
||||
<Badge variant={config.enabled ? 'success' : 'default'} className="ml-1">
|
||||
{config.enabled ? '已启用' : '已禁用'}
|
||||
</Badge>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => setConfig({ enabled: e.target.checked })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={handleExport} title="导出数据">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 统计概览 */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 p-3"
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1.5">
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
学习统计
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-500">{stats.totalEvents}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">事件</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-500">{stats.totalPatterns}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">模式</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-amber-500">{agentSuggestions.length}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">建议</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-purple-500">
|
||||
{(stats.avgConfidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">置信度</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{(['suggestions', 'events', 'patterns'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'text-emerald-600 dark:text-emerald-400 border-b-2 border-emerald-500'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'suggestions' && '建议'}
|
||||
{tab === 'events' && '事件'}
|
||||
{tab === 'patterns' && '模式'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'suggestions' && (
|
||||
<motion.div
|
||||
key="suggestions"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{agentSuggestions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Lightbulb className="w-8 h-8" />}
|
||||
title="暂无学习建议"
|
||||
description="系统会根据您的反馈自动生成改进建议"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentSuggestions.map(suggestion => (
|
||||
<SuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
onApply={() => handleApplySuggestion(suggestion.id)}
|
||||
onDismiss={() => handleDismissSuggestion(suggestion.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'events' && (
|
||||
<motion.div
|
||||
key="events"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{agentEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Clock className="w-8 h-8" />}
|
||||
title="暂无学习事件"
|
||||
description="开始对话后,系统会自动记录学习事件"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentEvents.map(event => (
|
||||
<EventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
onAcknowledge={() => handleAcknowledge(event.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'patterns' && (
|
||||
<motion.div
|
||||
key="patterns"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{agentPatterns.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<TrendingUp className="w-8 h-8" />}
|
||||
title="暂无学习模式"
|
||||
description="积累更多反馈后,系统会识别出行为模式"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentPatterns.map(pattern => {
|
||||
const typeInfo = PATTERN_TYPE_LABELS[pattern.type] || { label: pattern.type, icon: '📊' };
|
||||
return (
|
||||
<motion.div
|
||||
key={`${pattern.agentId}-${pattern.pattern}`}
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{typeInfo.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">{typeInfo.label}</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{(pattern.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{pattern.description}</p>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{pattern.examples.length} 个示例
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
上次更新: {agentEvents[0] ? getTimeAgo(agentEvents[0].timestamp) : '无'}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="text-red-500 hover:text-red-600">
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
|
||||
if (seconds < 60) return '刚刚';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`;
|
||||
return `${Math.floor(seconds / 86400)} 天前`;
|
||||
}
|
||||
|
||||
export default ActiveLearningPanel;
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
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: _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: {formatDate(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>
|
||||
);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
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, type FeedbackAttachment } from './feedbackStore';
|
||||
import { Button } from '../ui';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { silentErrorHandler } from '../../lib/error-utils';
|
||||
|
||||
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: FeedbackAttachment[] = await Promise.all(
|
||||
attachments.map(async (file) => {
|
||||
return new Promise<FeedbackAttachment>((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 {
|
||||
await submitFeedback({
|
||||
type,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
priority,
|
||||
attachments: processedAttachments,
|
||||
metadata: {
|
||||
appVersion: '0.0.0',
|
||||
os: navigator.platform,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
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(silentErrorHandler('FeedbackModal')); }}
|
||||
loading={isLoading}
|
||||
disabled={!title.trim() || !description.trim()}
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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): Promise<void> => {
|
||||
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,
|
||||
});
|
||||
} 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,
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
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';
|
||||
@@ -8,7 +8,7 @@ import { toChatAgent, useChatStore, type CodeBlock } from '../store/chatStore';
|
||||
import {
|
||||
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
||||
MessageSquare, Cpu, FileText, User, Activity, Brain,
|
||||
Shield, Sparkles, GraduationCap, List, Network, Dna
|
||||
Shield, Sparkles, List, Network, Dna
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Helper to extract code blocks from markdown content ===
|
||||
@@ -73,7 +73,6 @@ import { MemoryPanel } from './MemoryPanel';
|
||||
import { MemoryGraph } from './MemoryGraph';
|
||||
import { ReflectionLog } from './ReflectionLog';
|
||||
import { AutonomyConfig } from './AutonomyConfig';
|
||||
import { ActiveLearningPanel } from './ActiveLearningPanel';
|
||||
import { IdentityChangeProposalPanel } from './IdentityChangeProposal';
|
||||
import { CodeSnippetPanel, type CodeSnippet } from './CodeSnippetPanel';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
@@ -102,7 +101,7 @@ export function RightPanel() {
|
||||
const quickConfig = useConfigStore((s) => s.quickConfig);
|
||||
|
||||
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'learning' | 'evolution'>('status');
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'evolution'>('status');
|
||||
const [memoryViewMode, setMemoryViewMode] = useState<'list' | 'graph'>('list');
|
||||
const [isEditingAgent, setIsEditingAgent] = useState(false);
|
||||
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
|
||||
@@ -258,12 +257,6 @@ export function RightPanel() {
|
||||
icon={<Shield className="w-4 h-4" />}
|
||||
label="自主"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'learning'}
|
||||
onClick={() => setActiveTab('learning')}
|
||||
icon={<GraduationCap className="w-4 h-4" />}
|
||||
label="学习"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'evolution'}
|
||||
onClick={() => setActiveTab('evolution')}
|
||||
@@ -329,8 +322,6 @@ export function RightPanel() {
|
||||
<ReflectionLog />
|
||||
) : activeTab === 'autonomy' ? (
|
||||
<AutonomyConfig />
|
||||
) : activeTab === 'learning' ? (
|
||||
<ActiveLearningPanel />
|
||||
) : activeTab === 'evolution' ? (
|
||||
<IdentityChangeProposalPanel />
|
||||
) : activeTab === 'agent' ? (
|
||||
|
||||
@@ -9,7 +9,7 @@ export function About() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">ZCLAW</h1>
|
||||
<div className="text-sm text-gray-500">版本 0.2.0</div>
|
||||
<div className="text-sm text-gray-500">版本 0.1.0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getStoredGatewayToken, getStoredGatewayUrl } from '../../lib/gateway-client';
|
||||
import { useConnectionStore } from '../../store/connectionStore';
|
||||
import { useConfigStore } from '../../store/configStore';
|
||||
import { useChatStore } from '../../store/chatStore';
|
||||
import { silentErrorHandler } from '../../lib/error-utils';
|
||||
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X, Zap, Check } from 'lucide-react';
|
||||
|
||||
// 自定义模型数据结构
|
||||
interface CustomModel {
|
||||
@@ -18,6 +19,22 @@ interface CustomModel {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Embedding 配置数据结构
|
||||
interface EmbeddingConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
endpoint: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface EmbeddingProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
defaultModel: string;
|
||||
dimensions: number;
|
||||
}
|
||||
|
||||
// 可用的 Provider 列表
|
||||
// 注意: Coding Plan 是专为编程助手设计的优惠套餐,使用专用端点
|
||||
const AVAILABLE_PROVIDERS = [
|
||||
@@ -36,6 +53,42 @@ const AVAILABLE_PROVIDERS = [
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'zclaw-custom-models';
|
||||
const EMBEDDING_STORAGE_KEY = 'zclaw-embedding-config';
|
||||
|
||||
const DEFAULT_EMBEDDING_PROVIDERS: EmbeddingProvider[] = [
|
||||
{ id: 'local', name: '本地 TF-IDF (无需 API)', defaultModel: 'tfidf', dimensions: 0 },
|
||||
{ id: 'openai', name: 'OpenAI', defaultModel: 'text-embedding-3-small', dimensions: 1536 },
|
||||
{ id: 'zhipu', name: '智谱 AI', defaultModel: 'embedding-3', dimensions: 1024 },
|
||||
{ id: 'doubao', name: '火山引擎 (Doubao)', defaultModel: 'doubao-embedding', dimensions: 1024 },
|
||||
{ id: 'qwen', name: '百炼/通义千问', defaultModel: 'text-embedding-v3', dimensions: 1024 },
|
||||
{ id: 'deepseek', name: 'DeepSeek', defaultModel: 'deepseek-embedding', dimensions: 1536 },
|
||||
];
|
||||
|
||||
function loadEmbeddingConfig(): EmbeddingConfig {
|
||||
try {
|
||||
const stored = localStorage.getItem(EMBEDDING_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
provider: 'local',
|
||||
model: 'tfidf',
|
||||
apiKey: '',
|
||||
endpoint: '',
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function saveEmbeddingConfig(config: EmbeddingConfig): void {
|
||||
try {
|
||||
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 从 localStorage 加载自定义模型
|
||||
function loadCustomModels(): CustomModel[] {
|
||||
@@ -75,6 +128,12 @@ export function ModelsAPI() {
|
||||
const [editingModel, setEditingModel] = useState<CustomModel | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
// Embedding 配置状态
|
||||
const [embeddingConfig, setEmbeddingConfig] = useState<EmbeddingConfig>(loadEmbeddingConfig);
|
||||
const [showEmbeddingApiKey, setShowEmbeddingApiKey] = useState(false);
|
||||
const [testingEmbedding, setTestingEmbedding] = useState(false);
|
||||
const [embeddingTestResult, setEmbeddingTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
provider: 'zhipu',
|
||||
@@ -195,6 +254,65 @@ export function ModelsAPI() {
|
||||
});
|
||||
};
|
||||
|
||||
// Embedding Provider 变更
|
||||
const handleEmbeddingProviderChange = (providerId: string) => {
|
||||
const provider = DEFAULT_EMBEDDING_PROVIDERS.find(p => p.id === providerId);
|
||||
setEmbeddingConfig(prev => ({
|
||||
...prev,
|
||||
provider: providerId,
|
||||
model: provider?.defaultModel || 'tfidf',
|
||||
}));
|
||||
setEmbeddingTestResult(null);
|
||||
};
|
||||
|
||||
// 保存 Embedding 配置
|
||||
const handleSaveEmbeddingConfig = () => {
|
||||
const configToSave = {
|
||||
...embeddingConfig,
|
||||
enabled: embeddingConfig.provider !== 'local' && embeddingConfig.apiKey.trim() !== '',
|
||||
};
|
||||
setEmbeddingConfig(configToSave);
|
||||
saveEmbeddingConfig(configToSave);
|
||||
};
|
||||
|
||||
// 测试 Embedding API
|
||||
const handleTestEmbedding = async () => {
|
||||
if (embeddingConfig.provider === 'local') {
|
||||
setEmbeddingTestResult({ success: true, message: '本地 TF-IDF 模式无需测试' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!embeddingConfig.apiKey.trim()) {
|
||||
setEmbeddingTestResult({ success: false, message: '请先填写 API Key' });
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingEmbedding(true);
|
||||
setEmbeddingTestResult(null);
|
||||
|
||||
try {
|
||||
const result = await invoke<{ embedding: number[]; model: string }>('embedding_create', {
|
||||
provider: embeddingConfig.provider,
|
||||
apiKey: embeddingConfig.apiKey,
|
||||
text: '测试文本',
|
||||
model: embeddingConfig.model || undefined,
|
||||
endpoint: embeddingConfig.endpoint || undefined,
|
||||
});
|
||||
|
||||
setEmbeddingTestResult({
|
||||
success: true,
|
||||
message: `成功!向量维度: ${result.embedding.length}`,
|
||||
});
|
||||
} catch (error) {
|
||||
setEmbeddingTestResult({
|
||||
success: false,
|
||||
message: String(error),
|
||||
});
|
||||
} finally {
|
||||
setTestingEmbedding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -304,7 +422,125 @@ export function ModelsAPI() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 添加/编辑模型弹窗 */}
|
||||
{/* Embedding 模型配置 */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
Embedding 模型
|
||||
</h3>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${embeddingConfig.enabled ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-500'}`}>
|
||||
{embeddingConfig.enabled ? '已启用' : '使用 TF-IDF'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm space-y-4">
|
||||
{/* Provider 选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">服务商</label>
|
||||
<select
|
||||
value={embeddingConfig.provider}
|
||||
onChange={(e) => handleEmbeddingProviderChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
{DEFAULT_EMBEDDING_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} {p.dimensions > 0 ? `(${p.dimensions}D)` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 模型 ID */}
|
||||
{embeddingConfig.provider !== 'local' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">模型 ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={embeddingConfig.model}
|
||||
onChange={(e) => setEmbeddingConfig(prev => ({ ...prev, model: e.target.value }))}
|
||||
placeholder="text-embedding-3-small"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
默认: {DEFAULT_EMBEDDING_PROVIDERS.find(p => p.id === embeddingConfig.provider)?.defaultModel}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
{embeddingConfig.provider !== 'local' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showEmbeddingApiKey ? 'text' : 'password'}
|
||||
value={embeddingConfig.apiKey}
|
||||
onChange={(e) => setEmbeddingConfig(prev => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder="请填写 API Key"
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmbeddingApiKey(!showEmbeddingApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showEmbeddingApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自定义 Endpoint */}
|
||||
{embeddingConfig.provider !== 'local' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
自定义 Endpoint <span className="text-gray-400">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={embeddingConfig.endpoint}
|
||||
onChange={(e) => setEmbeddingConfig(prev => ({ ...prev, endpoint: e.target.value }))}
|
||||
placeholder="留空使用默认端点"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试结果 */}
|
||||
{embeddingTestResult && (
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm ${embeddingTestResult.success ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300' : 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'}`}>
|
||||
{embeddingTestResult.success ? <Check className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{embeddingTestResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSaveEmbeddingConfig}
|
||||
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
保存配置
|
||||
</button>
|
||||
{embeddingConfig.provider !== 'local' && (
|
||||
<button
|
||||
onClick={handleTestEmbedding}
|
||||
disabled={testingEmbedding || !embeddingConfig.apiKey.trim()}
|
||||
className="px-4 py-2 border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{testingEmbedding ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<p>Embedding 模型用于语义记忆的向量搜索,提供更精准的语义匹配。</p>
|
||||
<p className="mt-1">选择「本地 TF-IDF」无需配置 API,使用关键词匹配;选择其他服务商需配置 API Key。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setShowAddModal(false)} />
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* Workflow Recommendations Component
|
||||
*
|
||||
* Displays proactive workflow recommendations from the Adaptive Intelligence Mesh.
|
||||
* Shows detected patterns and suggested workflows based on user behavior.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useMeshStore } from '../store/meshStore';
|
||||
import type { WorkflowRecommendation, BehaviorPattern, PatternTypeVariant } from '../lib/intelligence-client';
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export const WorkflowRecommendations: React.FC = () => {
|
||||
const {
|
||||
recommendations,
|
||||
patterns,
|
||||
isLoading,
|
||||
error,
|
||||
analyze,
|
||||
acceptRecommendation,
|
||||
dismissRecommendation,
|
||||
} = useMeshStore();
|
||||
|
||||
const [selectedPattern, setSelectedPattern] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial analysis
|
||||
analyze();
|
||||
}, [analyze]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
<span className="ml-3 text-gray-400">Analyzing patterns...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Recommendations Section */}
|
||||
<section>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">💡</span>
|
||||
Recommended Workflows
|
||||
{recommendations.length > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
|
||||
{recommendations.length}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{recommendations.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-6 bg-gray-800/30 rounded-lg border border-gray-700/50 text-center"
|
||||
>
|
||||
<p className="text-gray-400">No recommendations available yet.</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Continue using the app to build up behavior patterns.
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recommendations.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.id}
|
||||
recommendation={rec}
|
||||
onAccept={() => acceptRecommendation(rec.id)}
|
||||
onDismiss={() => dismissRecommendation(rec.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
|
||||
{/* Detected Patterns Section */}
|
||||
<section>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">📊</span>
|
||||
Detected Patterns
|
||||
{patterns.length > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded-full">
|
||||
{patterns.length}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{patterns.length === 0 ? (
|
||||
<div className="p-6 bg-gray-800/30 rounded-lg border border-gray-700/50 text-center">
|
||||
<p className="text-gray-400">No patterns detected yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{patterns.map((pattern) => (
|
||||
<PatternCard
|
||||
key={pattern.id}
|
||||
pattern={pattern}
|
||||
isSelected={selectedPattern === pattern.id}
|
||||
onClick={() =>
|
||||
setSelectedPattern(
|
||||
selectedPattern === pattern.id ? null : pattern.id
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// === Sub-Components ===
|
||||
|
||||
interface RecommendationCardProps {
|
||||
recommendation: WorkflowRecommendation;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const RecommendationCard: React.FC<RecommendationCardProps> = ({
|
||||
recommendation,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const confidencePercent = Math.round(recommendation.confidence * 100);
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.8) return 'text-green-400';
|
||||
if (confidence >= 0.6) return 'text-yellow-400';
|
||||
return 'text-orange-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="p-4 bg-gray-800/50 rounded-lg border border-gray-700/50 hover:border-blue-500/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-white font-medium truncate">
|
||||
{recommendation.pipeline_id}
|
||||
</h4>
|
||||
<span
|
||||
className={`text-xs font-mono ${getConfidenceColor(
|
||||
recommendation.confidence
|
||||
)}`}
|
||||
>
|
||||
{confidencePercent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 text-sm mb-3">{recommendation.reason}</p>
|
||||
|
||||
{/* Suggested Inputs */}
|
||||
{Object.keys(recommendation.suggested_inputs).length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-gray-500 mb-1">Suggested inputs:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(recommendation.suggested_inputs).map(
|
||||
([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="px-2 py-0.5 bg-gray-700/50 text-gray-300 text-xs rounded"
|
||||
>
|
||||
{key}: {String(value).slice(0, 20)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Matched Patterns */}
|
||||
{recommendation.patterns_matched.length > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Based on {recommendation.patterns_matched.length} pattern(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={onAccept}
|
||||
className="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-sm rounded transition-colors"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confidence Bar */}
|
||||
<div className="mt-3 h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${confidencePercent}%` }}
|
||||
className={`h-full ${
|
||||
recommendation.confidence >= 0.8
|
||||
? 'bg-green-500'
|
||||
: recommendation.confidence >= 0.6
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-orange-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PatternCardProps {
|
||||
pattern: BehaviorPattern;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const PatternCard: React.FC<PatternCardProps> = ({
|
||||
pattern,
|
||||
isSelected,
|
||||
onClick,
|
||||
}) => {
|
||||
const getPatternTypeLabel = (type: PatternTypeVariant | string) => {
|
||||
// Handle object format
|
||||
const typeStr = typeof type === 'string' ? type : type.type;
|
||||
|
||||
switch (typeStr) {
|
||||
case 'SkillCombination':
|
||||
return { label: 'Skill Combo', icon: '⚡' };
|
||||
case 'TemporalTrigger':
|
||||
return { label: 'Time Trigger', icon: '⏰' };
|
||||
case 'TaskPipelineMapping':
|
||||
return { label: 'Task Mapping', icon: '🔄' };
|
||||
case 'InputPattern':
|
||||
return { label: 'Input Pattern', icon: '📝' };
|
||||
default:
|
||||
return { label: typeStr, icon: '📊' };
|
||||
}
|
||||
};
|
||||
|
||||
const { label, icon } = getPatternTypeLabel(pattern.pattern_type as PatternTypeVariant);
|
||||
const confidencePercent = Math.round(pattern.confidence * 100);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
onClick={onClick}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-purple-500/10 border-purple-500/50'
|
||||
: 'bg-gray-800/30 border-gray-700/50 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{icon}</span>
|
||||
<span className="text-white font-medium">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
{pattern.frequency}x used
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
pattern.confidence >= 0.6
|
||||
? 'text-green-400'
|
||||
: 'text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{confidencePercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="mt-3 pt-3 border-t border-gray-700/50 overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">ID:</span>{' '}
|
||||
<span className="text-gray-300 font-mono text-xs">
|
||||
{pattern.id}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">First seen:</span>{' '}
|
||||
<span className="text-gray-300">
|
||||
{new Date(pattern.first_occurrence).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Last seen:</span>{' '}
|
||||
<span className="text-gray-300">
|
||||
{new Date(pattern.last_occurrence).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{pattern.context.intent && (
|
||||
<div>
|
||||
<span className="text-gray-500">Intent:</span>{' '}
|
||||
<span className="text-gray-300">{pattern.context.intent}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowRecommendations;
|
||||
Reference in New Issue
Block a user