feat(ui): Phase 8 UI/UX optimization and system documentation update
## Sidebar Enhancement - Change tabs to icon + small label layout for better space utilization - Add Teams tab with team collaboration entry point ## Settings Page Improvements - Connect theme toggle to gatewayStore.saveQuickConfig for persistence - Remove OpenFang backend download section, simplify UI - Add time range filter to UsageStats (7d/30d/all) - Add stat cards with icons (sessions, messages, input/output tokens) - Add token usage overview bar chart - Add 8 ZCLAW system skill definitions with categories ## Bug Fixes - Fix ChannelList duplicate content with deduplication logic - Integrate CreateTriggerModal in TriggersPanel - Add independent SecurityStatusPanel with 12 default enabled layers - Change workflow view to use SchedulerPanel as unified entry ## New Components - CreateTriggerModal: Event trigger creation modal - HandApprovalModal: Hand approval workflow dialog - HandParamsForm: Enhanced Hand parameter form - SecurityLayersPanel: 16-layer security status display ## Architecture - Add TOML config parsing support (toml-utils.ts, config-parser.ts) - Add request timeout and retry mechanism (request-helper.ts) - Add secure token storage (secure-storage.ts, secure_storage.rs) ## Tests - Add unit tests for config-parser, toml-utils, request-helper - Add team-client and teamStore tests ## Documentation - Update SYSTEM_ANALYSIS.md with Phase 8 completion - UI completion: 100% (30/30 components) - API coverage: 93% (63/68 endpoints) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@ const CHANNEL_ICONS: Record<string, string> = {
|
||||
wechat: '微',
|
||||
};
|
||||
|
||||
// 可用频道类型(用于显示未配置的频道)
|
||||
const AVAILABLE_CHANNEL_TYPES = [
|
||||
{ type: 'feishu', name: '飞书 (Feishu)' },
|
||||
{ type: 'wechat', name: '微信' },
|
||||
{ type: 'qqbot', name: 'QQ 机器人' },
|
||||
];
|
||||
|
||||
interface ChannelListProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
@@ -27,6 +34,17 @@ export function ChannelList({ onOpenSettings }: ChannelListProps) {
|
||||
loadPluginStatus().then(() => loadChannels());
|
||||
};
|
||||
|
||||
// 去重:基于 channel id
|
||||
const uniqueChannels = channels.filter((ch, index, self) =>
|
||||
index === self.findIndex(c => c.id === ch.id)
|
||||
);
|
||||
|
||||
// 获取已配置的频道类型
|
||||
const configuredTypes = new Set(uniqueChannels.map(c => c.type));
|
||||
|
||||
// 未配置的频道类型
|
||||
const unconfiguredTypes = AVAILABLE_CHANNEL_TYPES.filter(ct => !configuredTypes.has(ct.type));
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
|
||||
@@ -53,7 +71,7 @@ export function ChannelList({ onOpenSettings }: ChannelListProps) {
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{/* Configured channels */}
|
||||
{channels.map((ch) => (
|
||||
{uniqueChannels.map((ch) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50"
|
||||
@@ -77,29 +95,18 @@ export function ChannelList({ onOpenSettings }: ChannelListProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Always show available channels that aren't configured */}
|
||||
{!channels.find(c => c.type === 'feishu') && (
|
||||
<div className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
|
||||
{/* Unconfigured channels - 只显示一次 */}
|
||||
{unconfiguredTypes.map((ct) => (
|
||||
<div key={ct.type} className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
|
||||
飞
|
||||
{CHANNEL_ICONS[ct.type] || <MessageCircle className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-600">飞书 (Feishu)</div>
|
||||
<div className="text-xs font-medium text-gray-600">{ct.name}</div>
|
||||
<div className="text-[11px] text-gray-400">未配置</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!channels.find(c => c.type === 'qqbot') && (
|
||||
<div className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
|
||||
QQ
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-600">QQ 机器人</div>
|
||||
<div className="text-[11px] text-gray-400">未安装插件</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
|
||||
{/* Help text */}
|
||||
<div className="px-3 py-4 text-center">
|
||||
|
||||
538
desktop/src/components/CreateTriggerModal.tsx
Normal file
538
desktop/src/components/CreateTriggerModal.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* CreateTriggerModal - Modal for creating event triggers
|
||||
*
|
||||
* Supports trigger types:
|
||||
* - webhook: External HTTP request trigger
|
||||
* - event: OpenFang internal event trigger
|
||||
* - message: Chat message pattern trigger
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import {
|
||||
Zap,
|
||||
X,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Types ===
|
||||
|
||||
type TriggerType = 'webhook' | 'event' | 'message';
|
||||
type TargetType = 'hand' | 'workflow';
|
||||
|
||||
interface TriggerFormData {
|
||||
name: string;
|
||||
type: TriggerType;
|
||||
pattern: string;
|
||||
targetType: TargetType;
|
||||
targetId: string;
|
||||
webhookPath: string;
|
||||
eventType: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface CreateTriggerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const initialFormData: TriggerFormData = {
|
||||
name: '',
|
||||
type: 'webhook',
|
||||
pattern: '',
|
||||
targetType: 'hand',
|
||||
targetId: '',
|
||||
webhookPath: '',
|
||||
eventType: '',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// === Trigger Type Options ===
|
||||
|
||||
const triggerTypeOptions: Array<{
|
||||
value: TriggerType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}> = [
|
||||
{
|
||||
value: 'webhook',
|
||||
label: 'Webhook',
|
||||
description: 'External HTTP request trigger',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
value: 'event',
|
||||
label: 'Event',
|
||||
description: 'OpenFang internal event trigger',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
value: 'message',
|
||||
label: 'Message',
|
||||
description: 'Chat message pattern trigger',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
];
|
||||
|
||||
// === Event Type Options ===
|
||||
|
||||
const eventTypeOptions = [
|
||||
{ value: 'file_changed', label: 'File Changed' },
|
||||
{ value: 'agent_started', label: 'Agent Started' },
|
||||
{ value: 'agent_stopped', label: 'Agent Stopped' },
|
||||
{ value: 'hand_completed', label: 'Hand Completed' },
|
||||
{ value: 'workflow_completed', label: 'Workflow Completed' },
|
||||
{ value: 'session_created', label: 'Session Created' },
|
||||
{ value: 'custom', label: 'Custom Event' },
|
||||
];
|
||||
|
||||
// === Component ===
|
||||
|
||||
export function CreateTriggerModal({ isOpen, onClose, onSuccess }: CreateTriggerModalProps) {
|
||||
const { hands, workflows, createTrigger, loadHands, loadWorkflows } = useGatewayStore();
|
||||
const [formData, setFormData] = useState<TriggerFormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
// Load available targets on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadHands();
|
||||
loadWorkflows();
|
||||
}
|
||||
}, [isOpen, loadHands, loadWorkflows]);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFormData(initialFormData);
|
||||
setErrors({});
|
||||
setSubmitStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Validate form
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Trigger name is required';
|
||||
}
|
||||
|
||||
switch (formData.type) {
|
||||
case 'webhook':
|
||||
if (!formData.webhookPath.trim()) {
|
||||
newErrors.webhookPath = 'Webhook path is required';
|
||||
} else if (!formData.webhookPath.startsWith('/')) {
|
||||
newErrors.webhookPath = 'Webhook path must start with /';
|
||||
}
|
||||
break;
|
||||
case 'event':
|
||||
if (!formData.eventType) {
|
||||
newErrors.eventType = 'Event type is required';
|
||||
}
|
||||
break;
|
||||
case 'message':
|
||||
if (!formData.pattern.trim()) {
|
||||
newErrors.pattern = 'Pattern is required';
|
||||
} else {
|
||||
// Validate regex pattern
|
||||
try {
|
||||
new RegExp(formData.pattern);
|
||||
} catch {
|
||||
newErrors.pattern = 'Invalid regular expression pattern';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!formData.targetId) {
|
||||
newErrors.targetId = 'Please select a target';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [formData]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
// Build config based on trigger type
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
switch (formData.type) {
|
||||
case 'webhook':
|
||||
config.path = formData.webhookPath;
|
||||
break;
|
||||
case 'event':
|
||||
config.eventType = formData.eventType;
|
||||
break;
|
||||
case 'message':
|
||||
config.pattern = formData.pattern;
|
||||
break;
|
||||
}
|
||||
|
||||
await createTrigger({
|
||||
type: formData.type,
|
||||
name: formData.name.trim(),
|
||||
enabled: formData.enabled,
|
||||
config,
|
||||
handName: formData.targetType === 'hand' ? formData.targetId : undefined,
|
||||
workflowId: formData.targetType === 'workflow' ? formData.targetId : undefined,
|
||||
});
|
||||
|
||||
setSubmitStatus('success');
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setSubmitStatus('error');
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to create trigger');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update form field
|
||||
const updateField = <K extends keyof TriggerFormData>(field: K, value: TriggerFormData[K]) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when field is updated
|
||||
if (errors[field]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get available targets based on type
|
||||
const getAvailableTargets = (): Array<{ id: string; name?: string }> => {
|
||||
switch (formData.targetType) {
|
||||
case 'hand':
|
||||
return hands.map(h => ({ id: h.id, name: h.name }));
|
||||
case 'workflow':
|
||||
return workflows.map(w => ({ id: w.id, name: w.name }));
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Create Event Trigger
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Create a trigger to respond to events
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Trigger Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Trigger Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
placeholder="e.g., Daily Report Webhook"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 ${
|
||||
errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trigger Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Trigger Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{triggerTypeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateField('type', option.value as TriggerType)}
|
||||
className={`flex flex-col items-center gap-1 p-3 text-sm rounded-lg border transition-colors ${
|
||||
formData.type === option.value
|
||||
? 'bg-amber-600 text-white border-amber-600'
|
||||
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-amber-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{triggerTypeOptions.find(o => o.value === formData.type)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Webhook Path */}
|
||||
{formData.type === 'webhook' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Webhook Path <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.webhookPath}
|
||||
onChange={(e) => updateField('webhookPath', e.target.value)}
|
||||
placeholder="/api/webhooks/daily-report"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 font-mono ${
|
||||
errors.webhookPath ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
{errors.webhookPath && (
|
||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{errors.webhookPath}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
The URL path that will trigger this action when called
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Type */}
|
||||
{formData.type === 'event' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Event Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.eventType}
|
||||
onChange={(e) => updateField('eventType', e.target.value)}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500 ${
|
||||
errors.eventType ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<option value="">-- Select Event Type --</option>
|
||||
{eventTypeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.eventType && (
|
||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{errors.eventType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Pattern */}
|
||||
{formData.type === 'message' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Message Pattern <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.pattern}
|
||||
onChange={(e) => updateField('pattern', e.target.value)}
|
||||
placeholder="^/report"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 font-mono ${
|
||||
errors.pattern ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
{errors.pattern && (
|
||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{errors.pattern}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Regular expression pattern to match chat messages
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Target Type
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: 'hand', label: 'Hand' },
|
||||
{ value: 'workflow', label: 'Workflow' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateField('targetType', option.value as TargetType);
|
||||
updateField('targetId', ''); // Reset target when type changes
|
||||
}}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
formData.targetType === option.value
|
||||
? 'bg-amber-600 text-white border-amber-600'
|
||||
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-amber-500'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Selection Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Select Target <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.targetId}
|
||||
onChange={(e) => updateField('targetId', e.target.value)}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500 ${
|
||||
errors.targetId ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<option value="">-- Please Select --</option>
|
||||
{getAvailableTargets().map((target) => (
|
||||
<option key={target.id} value={target.id}>
|
||||
{target.name || target.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.targetId && (
|
||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{errors.targetId}
|
||||
</p>
|
||||
)}
|
||||
{getAvailableTargets().length === 0 && (
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
No {formData.targetType === 'hand' ? 'Hands' : 'Workflows'} available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enabled Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="trigger-enabled"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => updateField('enabled', e.target.checked)}
|
||||
className="w-4 h-4 text-amber-600 border-gray-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<label htmlFor="trigger-enabled" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Enable immediately after creation
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{submitStatus === 'success' && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg text-green-700 dark:text-green-400">
|
||||
<CheckCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">Trigger created successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
{submitStatus === 'error' && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || submitStatus === 'success'}
|
||||
className="px-4 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4" />
|
||||
Create Trigger
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateTriggerModal;
|
||||
571
desktop/src/components/HandApprovalModal.tsx
Normal file
571
desktop/src/components/HandApprovalModal.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* HandApprovalModal - Modal for approving/rejecting Hand executions
|
||||
*
|
||||
* Provides detailed view of Hand execution request with:
|
||||
* - Hand name and description
|
||||
* - Trigger parameters
|
||||
* - Expected output/impact
|
||||
* - Risk level indicator
|
||||
* - Approval timeout countdown
|
||||
* - Approve/Reject buttons
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Shield,
|
||||
Zap,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import type { HandRun } from '../store/gatewayStore';
|
||||
import { HAND_DEFINITIONS, type HandId } from '../types/hands';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface HandApprovalData {
|
||||
runId: string;
|
||||
handId: HandId;
|
||||
handName: string;
|
||||
description: string;
|
||||
params: Record<string, unknown>;
|
||||
riskLevel: RiskLevel;
|
||||
expectedImpact?: string;
|
||||
requestedAt: string;
|
||||
requestedBy?: string;
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
|
||||
interface HandApprovalModalProps {
|
||||
handRun: HandRun | null;
|
||||
isOpen: boolean;
|
||||
onApprove: (runId: string) => Promise<void>;
|
||||
onReject: (runId: string, reason: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// === Risk Level Config ===
|
||||
|
||||
const RISK_CONFIG: Record<
|
||||
RiskLevel,
|
||||
{ label: string; color: string; bgColor: string; borderColor: string; icon: typeof AlertTriangle }
|
||||
> = {
|
||||
low: {
|
||||
label: 'Low Risk',
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
borderColor: 'border-green-300 dark:border-green-700',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium Risk',
|
||||
color: 'text-yellow-600 dark:text-yellow-400',
|
||||
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
borderColor: 'border-yellow-300 dark:border-yellow-700',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
high: {
|
||||
label: 'High Risk',
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
||||
borderColor: 'border-red-300 dark:border-red-700',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
};
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function calculateRiskLevel(handId: HandId, params: Record<string, unknown>): RiskLevel {
|
||||
// Risk assessment based on Hand type and parameters
|
||||
switch (handId) {
|
||||
case 'browser':
|
||||
// Browser automation can be high risk if interacting with sensitive sites
|
||||
const url = String(params.url || '').toLowerCase();
|
||||
if (url.includes('bank') || url.includes('payment') || url.includes('admin')) {
|
||||
return 'high';
|
||||
}
|
||||
if (params.headless === false) {
|
||||
return 'medium'; // Non-headless mode is more visible
|
||||
}
|
||||
return 'low';
|
||||
|
||||
case 'twitter':
|
||||
// Twitter actions can have public impact
|
||||
const action = String(params.action || '');
|
||||
if (action === 'post' || action === 'engage') {
|
||||
return 'high'; // Public posting is high impact
|
||||
}
|
||||
return 'medium';
|
||||
|
||||
case 'collector':
|
||||
// Data collection depends on scope
|
||||
if (params.pagination === true) {
|
||||
return 'medium'; // Large scale collection
|
||||
}
|
||||
return 'low';
|
||||
|
||||
case 'lead':
|
||||
// Lead generation accesses external data
|
||||
return 'medium';
|
||||
|
||||
case 'clip':
|
||||
// Video processing is generally safe
|
||||
return 'low';
|
||||
|
||||
case 'predictor':
|
||||
// Predictions are read-only
|
||||
return 'low';
|
||||
|
||||
case 'researcher':
|
||||
// Research is generally safe
|
||||
const depth = String(params.depth || '');
|
||||
return depth === 'deep' ? 'medium' : 'low';
|
||||
|
||||
default:
|
||||
return 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
function getExpectedImpact(handId: HandId, params: Record<string, unknown>): string {
|
||||
switch (handId) {
|
||||
case 'browser':
|
||||
return `Will perform browser automation on ${params.url || 'specified URL'}`;
|
||||
case 'twitter':
|
||||
if (params.action === 'post') {
|
||||
return 'Will post content to Twitter/X publicly';
|
||||
}
|
||||
if (params.action === 'engage') {
|
||||
return 'Will like/reply to tweets';
|
||||
}
|
||||
return 'Will perform Twitter/X operations';
|
||||
case 'collector':
|
||||
return `Will collect data from ${params.targetUrl || 'specified source'}`;
|
||||
case 'lead':
|
||||
return `Will search for leads from ${params.source || 'specified source'}`;
|
||||
case 'clip':
|
||||
return `Will process video: ${params.inputPath || 'specified input'}`;
|
||||
case 'predictor':
|
||||
return `Will run prediction on ${params.dataSource || 'specified data'}`;
|
||||
case 'researcher':
|
||||
return `Will conduct research on: ${params.topic || 'specified topic'}`;
|
||||
default:
|
||||
return 'Will execute Hand operation';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeRemaining(seconds: number): string {
|
||||
if (seconds <= 0) return 'Expired';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
|
||||
// === Countdown Timer Hook ===
|
||||
|
||||
function useCountdown(startedAt: string, timeoutSeconds: number = 300) {
|
||||
const [timeRemaining, setTimeRemaining] = useState(0);
|
||||
const [isExpired, setIsExpired] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const started = new Date(startedAt).getTime();
|
||||
const expiresAt = started + timeoutSeconds * 1000;
|
||||
|
||||
const updateTimer = () => {
|
||||
const now = Date.now();
|
||||
const remaining = Math.max(0, Math.floor((expiresAt - now) / 1000));
|
||||
setTimeRemaining(remaining);
|
||||
setIsExpired(remaining <= 0);
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
const interval = setInterval(updateTimer, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [startedAt, timeoutSeconds]);
|
||||
|
||||
return { timeRemaining, isExpired };
|
||||
}
|
||||
|
||||
// === Sub-components ===
|
||||
|
||||
function RiskBadge({ level }: { level: RiskLevel }) {
|
||||
const config = RISK_CONFIG[level];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-medium border ${config.bgColor} ${config.color} ${config.borderColor}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{config.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeoutProgress({ timeRemaining, totalSeconds }: { timeRemaining: number; totalSeconds: number }) {
|
||||
const percentage = Math.max(0, Math.min(100, (timeRemaining / totalSeconds) * 100));
|
||||
const isUrgent = timeRemaining < 60;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Time Remaining
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium ${isUrgent ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-300'}`}
|
||||
>
|
||||
{formatTimeRemaining(timeRemaining)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-1000 ease-linear rounded-full ${
|
||||
isUrgent ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamsDisplay({ params }: { params: Record<string, unknown> }) {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">No parameters provided</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre className="text-gray-700 dark:text-gray-300">
|
||||
{JSON.stringify(params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function HandApprovalModal({
|
||||
handRun,
|
||||
isOpen,
|
||||
onApprove,
|
||||
onReject,
|
||||
onClose,
|
||||
}: HandApprovalModalProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showRejectInput, setShowRejectInput] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Parse HandRun to get approval data
|
||||
const approvalData = useMemo((): HandApprovalData | null => {
|
||||
if (!handRun) return null;
|
||||
|
||||
// Extract hand ID from run data (could be stored in result or need to be passed separately)
|
||||
const result = handRun.result as Record<string, unknown> | undefined;
|
||||
const handId = (result?.handId as HandId) || 'researcher'; // Default fallback
|
||||
const params = (result?.params as Record<string, unknown>) || {};
|
||||
const handDef = HAND_DEFINITIONS.find((h) => h.id === handId);
|
||||
|
||||
return {
|
||||
runId: handRun.runId,
|
||||
handId,
|
||||
handName: handDef?.name || handId,
|
||||
description: handDef?.description || 'Hand execution request',
|
||||
params,
|
||||
riskLevel: calculateRiskLevel(handId, params),
|
||||
expectedImpact: getExpectedImpact(handId, params),
|
||||
requestedAt: handRun.startedAt,
|
||||
timeoutSeconds: 300, // 5 minutes default timeout
|
||||
};
|
||||
}, [handRun]);
|
||||
|
||||
// Timer countdown
|
||||
const { timeRemaining, isExpired } = useCountdown(
|
||||
approvalData?.requestedAt || new Date().toISOString(),
|
||||
approvalData?.timeoutSeconds || 300
|
||||
);
|
||||
|
||||
// Handle keyboard escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShowRejectInput(false);
|
||||
setRejectReason('');
|
||||
setError(null);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleApprove = useCallback(async () => {
|
||||
if (!approvalData || isProcessing || isExpired) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onApprove(approvalData.runId);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to approve');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [approvalData, isProcessing, isExpired, onApprove, onClose]);
|
||||
|
||||
const handleReject = useCallback(async () => {
|
||||
if (!approvalData || isProcessing) return;
|
||||
|
||||
if (!showRejectInput) {
|
||||
setShowRejectInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rejectReason.trim()) {
|
||||
setError('Please provide a reason for rejection');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onReject(approvalData.runId, rejectReason.trim());
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reject');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [approvalData, isProcessing, showRejectInput, rejectReason, onReject, onClose]);
|
||||
|
||||
const handleCancelReject = useCallback(() => {
|
||||
setShowRejectInput(false);
|
||||
setRejectReason('');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
if (!isOpen || !approvalData) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Hand Approval Request
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Review and approve Hand execution
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Expired Warning */}
|
||||
{isExpired && (
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-900 rounded-lg text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">This approval request has expired</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hand Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{approvalData.handName}
|
||||
</h3>
|
||||
</div>
|
||||
<RiskBadge level={approvalData.riskLevel} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{approvalData.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timeout Progress */}
|
||||
{!isExpired && (
|
||||
<TimeoutProgress
|
||||
timeRemaining={timeRemaining}
|
||||
totalSeconds={approvalData.timeoutSeconds || 300}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Parameters */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Execution Parameters
|
||||
</label>
|
||||
<ParamsDisplay params={approvalData.params} />
|
||||
</div>
|
||||
|
||||
{/* Expected Impact */}
|
||||
{approvalData.expectedImpact && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
Expected Impact
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||
{approvalData.expectedImpact}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Request Info */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<p>Run ID: {approvalData.runId}</p>
|
||||
<p>
|
||||
Requested: {new Date(approvalData.requestedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reject Input */}
|
||||
{showRejectInput && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Rejection Reason <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Please provide a reason for rejecting this request..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
rows={3}
|
||||
autoFocus
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-red-700 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
{showRejectInput ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelReject}
|
||||
disabled={isProcessing}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReject}
|
||||
disabled={isProcessing || !rejectReason.trim()}
|
||||
className="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Rejecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Confirm Rejection
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReject}
|
||||
disabled={isProcessing || isExpired}
|
||||
className="px-4 py-2 text-sm border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApprove}
|
||||
disabled={isProcessing || isExpired}
|
||||
className="px-4 py-2 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Approving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HandApprovalModal;
|
||||
812
desktop/src/components/HandParamsForm.tsx
Normal file
812
desktop/src/components/HandParamsForm.tsx
Normal file
@@ -0,0 +1,812 @@
|
||||
/**
|
||||
* HandParamsForm - Dynamic form component for Hand parameters
|
||||
*
|
||||
* Supports all parameter types:
|
||||
* - text: Text input
|
||||
* - number: Number input with min/max validation
|
||||
* - boolean: Toggle/checkbox
|
||||
* - select: Dropdown select
|
||||
* - textarea: Multi-line text
|
||||
* - array: Dynamic array with add/remove items
|
||||
* - object: JSON object editor
|
||||
* - file: File selector
|
||||
*
|
||||
* Features:
|
||||
* - Form validation (required, type, range, pattern)
|
||||
* - Parameter presets (save/load/delete)
|
||||
* - Error display below inputs
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
FolderOpen,
|
||||
Trash,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FileText,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import type { HandParameter } from '../types/hands';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface HandParamsFormProps {
|
||||
parameters: HandParameter[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
errors?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
presetKey?: string; // Key for storing/loading presets
|
||||
}
|
||||
|
||||
export interface ParameterPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// === Validation ===
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function validateParameter(param: HandParameter, value: unknown): ValidationResult {
|
||||
// Required check
|
||||
if (param.required) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
}
|
||||
}
|
||||
|
||||
// Skip further validation if value is empty and not required
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch (param.type) {
|
||||
case 'number':
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return { isValid: false, error: `${param.label} must be a valid number` };
|
||||
}
|
||||
if (param.min !== undefined && value < param.min) {
|
||||
return { isValid: false, error: `${param.label} must be at least ${param.min}` };
|
||||
}
|
||||
if (param.max !== undefined && value > param.max) {
|
||||
return { isValid: false, error: `${param.label} must be at most ${param.max}` };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
if (typeof value !== 'string') {
|
||||
return { isValid: false, error: `${param.label} must be text` };
|
||||
}
|
||||
if (param.pattern) {
|
||||
try {
|
||||
const regex = new RegExp(param.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return { isValid: false, error: `${param.label} format is invalid` };
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex pattern, skip validation
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) {
|
||||
return { isValid: false, error: `${param.label} must be an array` };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { isValid: false, error: `${param.label} must be an object` };
|
||||
}
|
||||
try {
|
||||
// Try to stringify to validate JSON
|
||||
JSON.stringify(value);
|
||||
} catch {
|
||||
return { isValid: false, error: `${param.label} contains invalid JSON` };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
if (typeof value !== 'string') {
|
||||
return { isValid: false, error: `${param.label} must be a file path` };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// === Preset Storage ===
|
||||
|
||||
const PRESET_STORAGE_PREFIX = 'zclaw-hand-preset-';
|
||||
|
||||
function getPresetStorageKey(handId: string): string {
|
||||
return `${PRESET_STORAGE_PREFIX}${handId}`;
|
||||
}
|
||||
|
||||
function loadPresets(handId: string): ParameterPreset[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(getPresetStorageKey(handId));
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as ParameterPreset[];
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function savePresets(handId: string, presets: ParameterPreset[]): void {
|
||||
try {
|
||||
localStorage.setItem(getPresetStorageKey(handId), JSON.stringify(presets));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// === Sub-Components ===
|
||||
|
||||
interface FormFieldWrapperProps {
|
||||
param: HandParameter;
|
||||
error?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function FormFieldWrapper({ param, error, children }: FormFieldWrapperProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{param.label}
|
||||
{param.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{param.description && !error && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Parameter Input Components ===
|
||||
|
||||
interface ParamInputProps {
|
||||
param: HandParameter;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function TextParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.placeholder}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={(value as number) ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onChange(val === '' ? undefined : parseFloat(val));
|
||||
}}
|
||||
placeholder={param.placeholder}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BooleanParamInput({ param, value, onChange, disabled }: ParamInputProps) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(value as boolean) ?? false}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{param.placeholder || 'Enabled'}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
return (
|
||||
<select
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<option value="">{param.placeholder || '-- Select --'}</option>
|
||||
{param.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
return (
|
||||
<textarea
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.placeholder}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
const [newItem, setNewItem] = useState('');
|
||||
const items = (Array.isArray(value) ? value : []) as string[];
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (newItem.trim()) {
|
||||
onChange([...items, newItem.trim()]);
|
||||
setNewItem('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const handleUpdateItem = (index: number, newValue: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = newValue;
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddItem();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 p-3 border rounded-lg ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}>
|
||||
{/* Existing Items */}
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => handleUpdateItem(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(index)}
|
||||
disabled={disabled}
|
||||
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Item */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={param.placeholder || 'Add item...'}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddItem}
|
||||
disabled={disabled || !newItem.trim()}
|
||||
className="p-1 text-blue-500 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !newItem && (
|
||||
<p className="text-xs text-gray-400 text-center">No items added yet</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
// Sync jsonText with value
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
setJsonText(JSON.stringify(value, null, 2));
|
||||
setParseError(null);
|
||||
} else {
|
||||
setJsonText('');
|
||||
}
|
||||
} catch {
|
||||
setJsonText('');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
setJsonText(text);
|
||||
|
||||
if (!text.trim()) {
|
||||
onChange({});
|
||||
setParseError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
onChange(parsed);
|
||||
setParseError(null);
|
||||
} else {
|
||||
setParseError('Value must be a JSON object');
|
||||
}
|
||||
} catch {
|
||||
setParseError('Invalid JSON format');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${error || parseError ? 'border-red-500' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{isExpanded ? 'Collapse' : 'Expand'} JSON Editor
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
placeholder={param.placeholder || '{\n "key": "value"\n}'}
|
||||
disabled={disabled}
|
||||
rows={6}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
|
||||
error || parseError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parseError && (
|
||||
<p className="text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{parseError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// For now, just store the file name. In a real implementation,
|
||||
// you might want to read the file contents or handle upload
|
||||
onChange(file.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${error ? 'border-red-500' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
className={`flex-1 flex items-center gap-2 px-3 py-2 text-sm border rounded-lg cursor-pointer transition-colors ${
|
||||
disabled
|
||||
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-50'
|
||||
: 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
<span className="flex-1 truncate text-gray-900 dark:text-white">
|
||||
{(value as string) || param.placeholder || 'Choose file...'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept={param.accept}
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{(value as string) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
disabled={disabled}
|
||||
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Parameter Field Component ===
|
||||
|
||||
function ParameterField({ param, value, onChange, disabled, externalError }: ParamInputProps & { externalError?: string }) {
|
||||
const [internalError, setInternalError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleChange = useCallback((newValue: unknown) => {
|
||||
// Validate on change
|
||||
const result = validateParameter(param, newValue);
|
||||
setInternalError(result.error ?? undefined);
|
||||
onChange(newValue);
|
||||
}, [param, onChange]);
|
||||
|
||||
const error = externalError || internalError;
|
||||
|
||||
const inputProps: ParamInputProps = { param, value, onChange: handleChange, disabled, error };
|
||||
|
||||
const renderInput = () => {
|
||||
switch (param.type) {
|
||||
case 'text':
|
||||
return <TextParamInput {...inputProps} />;
|
||||
case 'number':
|
||||
return <NumberParamInput {...inputProps} />;
|
||||
case 'boolean':
|
||||
return <BooleanParamInput {...inputProps} />;
|
||||
case 'select':
|
||||
return <SelectParamInput {...inputProps} />;
|
||||
case 'textarea':
|
||||
return <TextareaParamInput {...inputProps} />;
|
||||
case 'array':
|
||||
return <ArrayParamInput {...inputProps} />;
|
||||
case 'object':
|
||||
return <ObjectParamInput {...inputProps} />;
|
||||
case 'file':
|
||||
return <FileParamInput {...inputProps} />;
|
||||
default:
|
||||
return <TextParamInput {...inputProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormFieldWrapper param={param} error={error}>
|
||||
{renderInput()}
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// === Preset Manager Component ===
|
||||
|
||||
interface PresetManagerProps {
|
||||
presetKey?: string;
|
||||
currentValues: Record<string, unknown>;
|
||||
onLoadPreset: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManagerProps) {
|
||||
const [presets, setPresets] = useState<ParameterPreset[]>([]);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
const [showPresetList, setShowPresetList] = useState(false);
|
||||
|
||||
// Load presets on mount
|
||||
useEffect(() => {
|
||||
if (presetKey) {
|
||||
setPresets(loadPresets(presetKey));
|
||||
}
|
||||
}, [presetKey]);
|
||||
|
||||
const handleSavePreset = () => {
|
||||
if (!presetKey || !presetName.trim()) return;
|
||||
|
||||
const newPreset: ParameterPreset = {
|
||||
id: `preset-${Date.now()}`,
|
||||
name: presetName.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
values: { ...currentValues },
|
||||
};
|
||||
|
||||
const newPresets = [...presets, newPreset];
|
||||
setPresets(newPresets);
|
||||
savePresets(presetKey, newPresets);
|
||||
setPresetName('');
|
||||
setShowSaveDialog(false);
|
||||
};
|
||||
|
||||
const handleLoadPreset = (preset: ParameterPreset) => {
|
||||
onLoadPreset(preset.values);
|
||||
setShowPresetList(false);
|
||||
};
|
||||
|
||||
const handleDeletePreset = (presetId: string) => {
|
||||
if (!presetKey) return;
|
||||
const newPresets = presets.filter((p) => p.id !== presetId);
|
||||
setPresets(newPresets);
|
||||
savePresets(presetKey, newPresets);
|
||||
};
|
||||
|
||||
if (!presetKey) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
Save Preset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPresetList(!showPresetList)}
|
||||
disabled={presets.length === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
Load Preset ({presets.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Save Dialog */}
|
||||
{showSaveDialog && (
|
||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Preset Name
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="My preset..."
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSavePreset();
|
||||
if (e.key === 'Escape') setShowSaveDialog(false);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSavePreset}
|
||||
disabled={!presetName.trim()}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveDialog(false)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preset List */}
|
||||
{showPresetList && presets.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Available Presets
|
||||
</label>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{presets.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{preset.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(preset.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLoadPreset(preset)}
|
||||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeletePreset(preset.id)}
|
||||
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
<Trash className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function HandParamsForm({
|
||||
parameters,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
disabled,
|
||||
presetKey,
|
||||
}: HandParamsFormProps) {
|
||||
// Initialize values with defaults
|
||||
const initialValues = useMemo(() => {
|
||||
const result: Record<string, unknown> = { ...values };
|
||||
parameters.forEach((param) => {
|
||||
if (result[param.name] === undefined && param.defaultValue !== undefined) {
|
||||
result[param.name] = param.defaultValue;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [parameters, values]);
|
||||
|
||||
// Update parent when initialValues changes
|
||||
useEffect(() => {
|
||||
const hasMissingDefaults = parameters.some(
|
||||
(p) => values[p.name] === undefined && p.defaultValue !== undefined
|
||||
);
|
||||
if (hasMissingDefaults) {
|
||||
onChange(initialValues);
|
||||
}
|
||||
}, [initialValues, parameters, values, onChange]);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(paramName: string, value: unknown) => {
|
||||
onChange({
|
||||
...values,
|
||||
[paramName]: value,
|
||||
});
|
||||
},
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
const handleLoadPreset = useCallback(
|
||||
(presetValues: Record<string, unknown>) => {
|
||||
onChange({
|
||||
...values,
|
||||
...presetValues,
|
||||
});
|
||||
},
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
if (parameters.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||
No parameters required for this Hand.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Preset Manager */}
|
||||
<PresetManager
|
||||
presetKey={presetKey}
|
||||
currentValues={values}
|
||||
onLoadPreset={handleLoadPreset}
|
||||
/>
|
||||
|
||||
{/* Parameter Fields - Grid Layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{parameters.map((param) => (
|
||||
<div
|
||||
key={param.name}
|
||||
className={param.type === 'textarea' || param.type === 'object' || param.type === 'array' ? 'md:col-span-2' : ''}
|
||||
>
|
||||
<ParameterField
|
||||
param={param}
|
||||
value={values[param.name]}
|
||||
onChange={(value) => handleFieldChange(param.name, value)}
|
||||
disabled={disabled}
|
||||
externalError={errors?.[param.name]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Validation Export ===
|
||||
|
||||
export function validateAllParameters(
|
||||
parameters: HandParameter[],
|
||||
values: Record<string, unknown>
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
parameters.forEach((param) => {
|
||||
const result = validateParameter(param, values[param.name]);
|
||||
if (!result.isValid && result.error) {
|
||||
errors[param.name] = result.error;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export default HandParamsForm;
|
||||
722
desktop/src/components/SecurityLayersPanel.tsx
Normal file
722
desktop/src/components/SecurityLayersPanel.tsx
Normal file
@@ -0,0 +1,722 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Key,
|
||||
Users,
|
||||
Gauge,
|
||||
Clock,
|
||||
LockKeyhole,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Box,
|
||||
Globe,
|
||||
Cpu,
|
||||
DoorOpen as Gate,
|
||||
MessageSquareWarning,
|
||||
Filter,
|
||||
Radar,
|
||||
Siren,
|
||||
RefreshCw,
|
||||
Lock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
import type { SecurityLayer, SecurityStatus } from '../store/gatewayStore';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
|
||||
// OpenFang 16-layer security architecture definitions
|
||||
export const SECURITY_LAYERS: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
nameZh: string;
|
||||
description: string;
|
||||
category: 'input' | 'auth' | 'authz' | 'rate' | 'session' | 'encryption' | 'audit' | 'integrity' | 'sandbox' | 'network' | 'resource' | 'capability' | 'prompt' | 'output' | 'anomaly' | 'incident';
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}> = [
|
||||
{
|
||||
id: 'input.validation',
|
||||
name: 'Input Validation Layer',
|
||||
nameZh: '输入验证',
|
||||
description: 'Validates and sanitizes all user inputs to prevent injection attacks and malformed data.',
|
||||
category: 'input',
|
||||
icon: Filter,
|
||||
},
|
||||
{
|
||||
id: 'auth.identity',
|
||||
name: 'Authentication Layer',
|
||||
nameZh: '身份认证',
|
||||
description: 'Ed25519 cryptographic authentication with JWT tokens for secure identity verification.',
|
||||
category: 'auth',
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
id: 'auth.rbac',
|
||||
name: 'Authorization Layer (RBAC)',
|
||||
nameZh: '权限控制',
|
||||
description: 'Role-based access control with fine-grained permissions and capability gates.',
|
||||
category: 'authz',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'rate.limit',
|
||||
name: 'Rate Limiting Layer',
|
||||
nameZh: '速率限制',
|
||||
description: 'Prevents abuse by limiting request frequency per user, IP, and endpoint.',
|
||||
category: 'rate',
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
id: 'session.management',
|
||||
name: 'Session Management Layer',
|
||||
nameZh: '会话管理',
|
||||
description: 'Secure session handling with automatic expiration, rotation, and invalidation.',
|
||||
category: 'session',
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
id: 'encryption',
|
||||
name: 'Encryption Layer',
|
||||
nameZh: '数据加密',
|
||||
description: 'End-to-end encryption for data at rest and in transit using AES-256-GCM.',
|
||||
category: 'encryption',
|
||||
icon: LockKeyhole,
|
||||
},
|
||||
{
|
||||
id: 'audit.logging',
|
||||
name: 'Audit Logging Layer',
|
||||
nameZh: '审计日志',
|
||||
description: 'Merkle hash chain audit logging for tamper-evident operation records.',
|
||||
category: 'audit',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: 'integrity',
|
||||
name: 'Integrity Verification Layer',
|
||||
nameZh: '完整性验证',
|
||||
description: 'Cryptographic verification of code and data integrity using SHA-256 hashes.',
|
||||
category: 'integrity',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
id: 'sandbox',
|
||||
name: 'Sandbox Isolation Layer',
|
||||
nameZh: '沙箱隔离',
|
||||
description: 'Isolated execution environments for untrusted code and operations.',
|
||||
category: 'sandbox',
|
||||
icon: Box,
|
||||
},
|
||||
{
|
||||
id: 'network.security',
|
||||
name: 'Network Security Layer',
|
||||
nameZh: '网络安全',
|
||||
description: 'TLS 1.3 encryption, firewall rules, and network segmentation.',
|
||||
category: 'network',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: 'resource.limits',
|
||||
name: 'Resource Limits Layer',
|
||||
nameZh: '资源限制',
|
||||
description: 'Memory, CPU, and I/O limits to prevent resource exhaustion attacks.',
|
||||
category: 'resource',
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
id: 'capability.gates',
|
||||
name: 'Capability Gates Layer',
|
||||
nameZh: '能力门控',
|
||||
description: 'Explicit permission gates for sensitive operations like file access and network calls.',
|
||||
category: 'capability',
|
||||
icon: Gate,
|
||||
},
|
||||
{
|
||||
id: 'prompt.defense',
|
||||
name: 'Prompt Injection Defense',
|
||||
nameZh: '提示注入防御',
|
||||
description: 'Detects and mitigates prompt injection and jailbreak attempts.',
|
||||
category: 'prompt',
|
||||
icon: MessageSquareWarning,
|
||||
},
|
||||
{
|
||||
id: 'output.filter',
|
||||
name: 'Output Filtering Layer',
|
||||
nameZh: '输出过滤',
|
||||
description: 'Filters sensitive information from outputs and enforces content policies.',
|
||||
category: 'output',
|
||||
icon: Filter,
|
||||
},
|
||||
{
|
||||
id: 'anomaly.detection',
|
||||
name: 'Anomaly Detection Layer',
|
||||
nameZh: '异常检测',
|
||||
description: 'ML-based detection of unusual patterns and potential security threats.',
|
||||
category: 'anomaly',
|
||||
icon: Radar,
|
||||
},
|
||||
{
|
||||
id: 'incident.response',
|
||||
name: 'Incident Response Layer',
|
||||
nameZh: '事件响应',
|
||||
description: 'Automated incident detection, alerting, and response orchestration.',
|
||||
category: 'incident',
|
||||
icon: Siren,
|
||||
},
|
||||
];
|
||||
|
||||
// Category groupings for UI organization
|
||||
const LAYER_CATEGORIES = {
|
||||
perimeter: {
|
||||
label: 'Perimeter Defense',
|
||||
labelZh: '边界防护',
|
||||
layers: ['input.validation', 'network.security', 'rate.limit'],
|
||||
},
|
||||
identity: {
|
||||
label: 'Identity & Access',
|
||||
labelZh: '身份与访问',
|
||||
layers: ['auth.identity', 'auth.rbac', 'session.management'],
|
||||
},
|
||||
data: {
|
||||
label: 'Data Protection',
|
||||
labelZh: '数据保护',
|
||||
layers: ['encryption', 'integrity', 'output.filter'],
|
||||
},
|
||||
execution: {
|
||||
label: 'Execution Safety',
|
||||
labelZh: '执行安全',
|
||||
layers: ['sandbox', 'resource.limits', 'capability.gates', 'prompt.defense'],
|
||||
},
|
||||
monitoring: {
|
||||
label: 'Monitoring & Response',
|
||||
labelZh: '监控与响应',
|
||||
layers: ['audit.logging', 'anomaly.detection', 'incident.response'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface LayerStatus {
|
||||
status: 'active' | 'warning' | 'inactive';
|
||||
details?: string;
|
||||
}
|
||||
|
||||
function getLayerStatus(layer: SecurityLayer): LayerStatus {
|
||||
if (layer.enabled) {
|
||||
return { status: 'active', details: layer.description };
|
||||
}
|
||||
return { status: 'inactive', details: layer.description };
|
||||
}
|
||||
|
||||
function getStatusIcon(status: 'active' | 'warning' | 'inactive') {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <ShieldCheck className="w-4 h-4 text-green-500" />;
|
||||
case 'warning':
|
||||
return <ShieldAlert className="w-4 h-4 text-yellow-500" />;
|
||||
case 'inactive':
|
||||
return <ShieldX className="w-4 h-4 text-red-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: 'active' | 'warning' | 'inactive') {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-50 border-green-200 text-green-700';
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 border-yellow-200 text-yellow-700';
|
||||
case 'inactive':
|
||||
return 'bg-gray-50 border-gray-200 text-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
interface LayerRowProps {
|
||||
layerDef: typeof SECURITY_LAYERS[0];
|
||||
status: LayerStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function LayerRow({ layerDef, status, expanded, onToggle }: LayerRowProps) {
|
||||
const Icon = layerDef.icon;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border transition-all ${getStatusColor(status.status)}`}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left"
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{layerDef.nameZh}</span>
|
||||
<span className="text-xs opacity-60 truncate">{layerDef.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusIcon(status.status)}
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 pt-0">
|
||||
<p className="text-xs leading-relaxed opacity-80">{layerDef.description}</p>
|
||||
{status.details && status.details !== layerDef.description && (
|
||||
<p className="text-xs mt-1 opacity-60">{status.details}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategorySectionProps {
|
||||
categoryKey: keyof typeof LAYER_CATEGORIES;
|
||||
layers: SecurityLayer[];
|
||||
expandedLayers: Set<string>;
|
||||
onToggleLayer: (id: string) => void;
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
categoryKey,
|
||||
layers,
|
||||
expandedLayers,
|
||||
onToggleLayer,
|
||||
}: CategorySectionProps) {
|
||||
const category = LAYER_CATEGORIES[categoryKey];
|
||||
const categoryLayers = category.layers
|
||||
.map((id) => SECURITY_LAYERS.find((l) => l.id === id))
|
||||
.filter(Boolean)
|
||||
.map((layerDef) => {
|
||||
const apiLayer = layers.find((l) => l.name === layerDef!.id);
|
||||
return {
|
||||
def: layerDef!,
|
||||
status: getLayerStatus(apiLayer || { name: layerDef!.id, enabled: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const activeCount = categoryLayers.filter((l) => l.status.status === 'active').length;
|
||||
const totalCount = categoryLayers.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs font-medium text-gray-600">{category.labelZh}</span>
|
||||
<span className={`text-xs ${activeCount === totalCount ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{activeCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{categoryLayers.map(({ def, status }) => (
|
||||
<LayerRow
|
||||
key={def.id}
|
||||
layerDef={def}
|
||||
status={status}
|
||||
expanded={expandedLayers.has(def.id)}
|
||||
onToggle={() => onToggleLayer(def.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SecurityLayersPanelProps {
|
||||
status: SecurityStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SecurityLayersPanel({ status, className = '' }: SecurityLayersPanelProps) {
|
||||
const [expandedLayers, setExpandedLayers] = useState<Set<string>>(new Set());
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(Object.keys(LAYER_CATEGORIES))
|
||||
);
|
||||
|
||||
const toggleLayer = (id: string) => {
|
||||
setExpandedLayers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCategory = (key: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate security score
|
||||
const activeLayers = status.layers.filter((l) => l.enabled).length;
|
||||
const totalLayers = SECURITY_LAYERS.length;
|
||||
const score = Math.round((activeLayers / totalLayers) * 100);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Security Score Circle */}
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
className="text-gray-100"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${score * 2.83} 283`}
|
||||
className={
|
||||
score >= 90
|
||||
? 'text-green-500'
|
||||
: score >= 70
|
||||
? 'text-blue-500'
|
||||
: score >= 50
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span
|
||||
className={`text-3xl font-bold ${
|
||||
score >= 90
|
||||
? 'text-green-600'
|
||||
: score >= 70
|
||||
? 'text-blue-600'
|
||||
: score >= 50
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">Security Score</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 px-1">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-green-600">{activeLayers}</div>
|
||||
<div className="text-xs text-gray-500">Active</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-yellow-600">
|
||||
{status.layers.filter((l) => !l.enabled).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Inactive</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-gray-600">{totalLayers}</div>
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Sections */}
|
||||
<div className="space-y-4">
|
||||
{(Object.keys(LAYER_CATEGORIES) as Array<keyof typeof LAYER_CATEGORIES>).map(
|
||||
(categoryKey) => (
|
||||
<div key={categoryKey} className="bg-gray-50 rounded-lg p-3">
|
||||
<button
|
||||
onClick={() => toggleCategory(categoryKey)}
|
||||
className="w-full flex items-center justify-between mb-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{LAYER_CATEGORIES[categoryKey].labelZh}
|
||||
</span>
|
||||
</div>
|
||||
{expandedCategories.has(categoryKey) ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{expandedCategories.has(categoryKey) && (
|
||||
<CategorySection
|
||||
categoryKey={categoryKey}
|
||||
layers={status.layers}
|
||||
expandedLayers={expandedLayers}
|
||||
onToggleLayer={toggleLayer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility function to calculate security score
|
||||
export function calculateSecurityScore(layers: SecurityLayer[]): number {
|
||||
const activeCount = layers.filter((l) => l.enabled).length;
|
||||
return Math.round((activeCount / SECURITY_LAYERS.length) * 100);
|
||||
}
|
||||
|
||||
// ZCLAW 默认安全状态(独立于 OpenFang)
|
||||
export function getDefaultSecurityStatus(): SecurityStatus {
|
||||
// ZCLAW 默认启用的安全层
|
||||
const defaultEnabledLayers = [
|
||||
'input.validation',
|
||||
'auth.identity',
|
||||
'session.management',
|
||||
'encryption',
|
||||
'audit.logging',
|
||||
'integrity',
|
||||
'sandbox',
|
||||
'network.security',
|
||||
'capability.gates',
|
||||
'prompt.defense',
|
||||
'output.filter',
|
||||
'anomaly.detection',
|
||||
];
|
||||
|
||||
const layers: SecurityLayer[] = SECURITY_LAYERS.map((layer) => ({
|
||||
name: layer.id,
|
||||
enabled: defaultEnabledLayers.includes(layer.id),
|
||||
description: layer.description,
|
||||
}));
|
||||
|
||||
const enabledCount = layers.filter((l) => l.enabled).length;
|
||||
|
||||
return {
|
||||
layers,
|
||||
enabledCount,
|
||||
totalCount: layers.length,
|
||||
securityLevel: enabledCount >= 12 ? 'critical' : enabledCount >= 8 ? 'high' : 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
// === 独立安全状态面板组件 ===
|
||||
|
||||
interface SecurityStatusPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SecurityStatusPanel({ className = '' }: SecurityStatusPanelProps) {
|
||||
const { securityStatus, securityStatusLoading, loadSecurityStatus, connectionState } = useGatewayStore();
|
||||
const [localStatus, setLocalStatus] = useState<SecurityStatus>(getDefaultSecurityStatus());
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
// 加载安全状态
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadSecurityStatus();
|
||||
}
|
||||
}, [connected, loadSecurityStatus]);
|
||||
|
||||
// 当从 API 获取到安全状态时,使用 API 数据,否则使用本地默认状态
|
||||
const displayStatus = connected && securityStatus ? securityStatus : localStatus;
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
if (connected) {
|
||||
await loadSecurityStatus();
|
||||
} else {
|
||||
// 如果没有连接,刷新本地状态
|
||||
setLocalStatus(getDefaultSecurityStatus());
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const score = calculateSecurityScore(displayStatus.layers);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">安全状态</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{connected ? '已连接' : '本地模式'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || securityStatusLoading}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-500 rounded-md transition-colors disabled:opacity-50"
|
||||
title="刷新安全状态"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing || securityStatusLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全评分 */}
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="relative w-28 h-28">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
className="text-gray-100 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${score * 2.83} 283`}
|
||||
className={
|
||||
score >= 90
|
||||
? 'text-green-500'
|
||||
: score >= 70
|
||||
? 'text-blue-500'
|
||||
: score >= 50
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span
|
||||
className={`text-2xl font-bold ${
|
||||
score >= 90
|
||||
? 'text-green-600'
|
||||
: score >= 70
|
||||
? 'text-blue-600'
|
||||
: score >= 50
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500">安全评分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态统计 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-semibold text-green-600">
|
||||
{displayStatus.layers.filter((l) => l.enabled).length}
|
||||
</div>
|
||||
<div className="text-xs text-green-600">已启用</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-semibold text-yellow-600">
|
||||
{displayStatus.layers.filter((l) => !l.enabled).length}
|
||||
</div>
|
||||
<div className="text-xs text-yellow-600">未启用</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-semibold text-gray-600">
|
||||
{displayStatus.totalCount}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">总层数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全等级 */}
|
||||
<div className={`rounded-lg p-3 border ${
|
||||
displayStatus.securityLevel === 'critical'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: displayStatus.securityLevel === 'high'
|
||||
? 'bg-blue-50 border-blue-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className={`w-4 h-4 ${
|
||||
displayStatus.securityLevel === 'critical'
|
||||
? 'text-green-600'
|
||||
: displayStatus.securityLevel === 'high'
|
||||
? 'text-blue-600'
|
||||
: 'text-yellow-600'
|
||||
}`} />
|
||||
<span className={`text-sm font-medium ${
|
||||
displayStatus.securityLevel === 'critical'
|
||||
? 'text-green-700'
|
||||
: displayStatus.securityLevel === 'high'
|
||||
? 'text-blue-700'
|
||||
: 'text-yellow-700'
|
||||
}`}>
|
||||
{displayStatus.securityLevel === 'critical'
|
||||
? '最高安全等级'
|
||||
: displayStatus.securityLevel === 'high'
|
||||
? '高安全等级'
|
||||
: '中等安全等级'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{!connected && 'ZCLAW 默认安全配置。连接 OpenFang 后可获取完整安全状态。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 快速查看安全层 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">安全层概览</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SECURITY_LAYERS.slice(0, 8).map((layer) => {
|
||||
const isEnabled = displayStatus.layers.find((l) => l.name === layer.id)?.enabled ?? false;
|
||||
const Icon = layer.icon;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-xs ${
|
||||
isEnabled
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
title={layer.nameZh}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">{layer.nameZh}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-md text-xs bg-gray-50 text-gray-400">
|
||||
<span>+8 层</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export layer definitions for use in other components
|
||||
export { SECURITY_LAYERS as SECURITY_LAYERS_DEFINITION };
|
||||
@@ -1,19 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useGatewayStore } from '../../store/gatewayStore';
|
||||
import { useChatStore } from '../../store/chatStore';
|
||||
import { getStoredGatewayToken, setStoredGatewayToken } from '../../lib/gateway-client';
|
||||
|
||||
export function General() {
|
||||
const { connectionState, gatewayVersion, error, connect, disconnect } = useGatewayStore();
|
||||
const { connectionState, gatewayVersion, error, connect, disconnect, quickConfig, saveQuickConfig } = useGatewayStore();
|
||||
const { currentModel } = useChatStore();
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||
const [autoStart, setAutoStart] = useState(false);
|
||||
const [showToolCalls, setShowToolCalls] = useState(false);
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(quickConfig.theme || 'light');
|
||||
const [autoStart, setAutoStart] = useState(quickConfig.autoStart ?? false);
|
||||
const [showToolCalls, setShowToolCalls] = useState(quickConfig.showToolCalls ?? false);
|
||||
const [gatewayToken, setGatewayToken] = useState(getStoredGatewayToken());
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
|
||||
|
||||
// 同步主题设置
|
||||
useEffect(() => {
|
||||
if (quickConfig.theme) {
|
||||
setTheme(quickConfig.theme);
|
||||
}
|
||||
if (quickConfig.autoStart !== undefined) {
|
||||
setAutoStart(quickConfig.autoStart);
|
||||
}
|
||||
if (quickConfig.showToolCalls !== undefined) {
|
||||
setShowToolCalls(quickConfig.showToolCalls);
|
||||
}
|
||||
}, [quickConfig.theme, quickConfig.autoStart, quickConfig.showToolCalls]);
|
||||
|
||||
// 应用主题到文档
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const handleThemeChange = async (newTheme: 'light' | 'dark') => {
|
||||
setTheme(newTheme);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveQuickConfig({ theme: newTheme });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoStartChange = async (value: boolean) => {
|
||||
setAutoStart(value);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveQuickConfig({ autoStart: value });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowToolCallsChange = async (value: boolean) => {
|
||||
setShowToolCalls(value);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveQuickConfig({ showToolCalls: value });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
connect(undefined, gatewayToken || undefined).catch(() => {});
|
||||
};
|
||||
@@ -93,12 +146,14 @@ export function General() {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`w-8 h-8 rounded-full border-2 ${theme === 'light' ? 'border-orange-500' : 'border-gray-300'} bg-white`}
|
||||
onClick={() => handleThemeChange('light')}
|
||||
disabled={isSaving}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-all ${theme === 'light' ? 'border-orange-500 ring-2 ring-orange-200' : 'border-gray-300'} bg-white disabled:opacity-50`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`w-8 h-8 rounded-full border-2 ${theme === 'dark' ? 'border-orange-500' : 'border-gray-300'} bg-gray-900`}
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
disabled={isSaving}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-all ${theme === 'dark' ? 'border-orange-500 ring-2 ring-orange-200' : 'border-gray-300'} bg-gray-900 disabled:opacity-50`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +163,7 @@ export function General() {
|
||||
<div className="text-sm font-medium text-gray-900">开机自启</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">登录时自动启动 ZCLAW。</div>
|
||||
</div>
|
||||
<Toggle checked={autoStart} onChange={setAutoStart} />
|
||||
<Toggle checked={autoStart} onChange={handleAutoStartChange} disabled={isSaving} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -116,37 +171,19 @@ export function General() {
|
||||
<div className="text-sm font-medium text-gray-900">显示工具调用</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">在对话消息中显示模型的工具调用详情块。</div>
|
||||
</div>
|
||||
<Toggle checked={showToolCalls} onChange={setShowToolCalls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3 mt-6">OpenFang 后端</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-5 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">默认端口</span>
|
||||
<span className="text-sm text-gray-500 font-mono">50051</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">协议</span>
|
||||
<span className="text-sm text-gray-500">WebSocket + REST API</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">配置格式</span>
|
||||
<span className="text-sm text-gray-500">TOML</span>
|
||||
</div>
|
||||
<div className="text-xs text-blue-700 bg-blue-50 rounded-lg p-3">
|
||||
OpenFang 提供 7 个自主能力包 (Hands)、工作流引擎、16 层安全防护。需下载 OpenFang 运行时。
|
||||
<Toggle checked={showToolCalls} onChange={handleShowToolCallsChange} disabled={isSaving} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 ${checked ? 'bg-orange-500' : 'bg-gray-300'}`}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 ${checked ? 'bg-orange-500' : 'bg-gray-300'} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className={`block w-5 h-5 bg-white rounded-full shadow absolute top-0.5 transition-all ${checked ? 'left-[22px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useGatewayStore } from '../../store/gatewayStore';
|
||||
import { Wrench, Zap, FileCode, Globe, Mail, Database, Search, MessageSquare } from 'lucide-react';
|
||||
|
||||
// ZCLAW 内置系统技能
|
||||
const SYSTEM_SKILLS = [
|
||||
{
|
||||
id: 'code-assistant',
|
||||
name: '代码助手',
|
||||
description: '代码编写、调试、重构和优化',
|
||||
category: '开发',
|
||||
icon: FileCode,
|
||||
},
|
||||
{
|
||||
id: 'web-search',
|
||||
name: '网络搜索',
|
||||
description: '实时搜索互联网信息',
|
||||
category: '信息',
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
id: 'file-manager',
|
||||
name: '文件管理',
|
||||
description: '文件读写、搜索和整理',
|
||||
category: '系统',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
id: 'web-browsing',
|
||||
name: '网页浏览',
|
||||
description: '访问和解析网页内容',
|
||||
category: '信息',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: 'email-handler',
|
||||
name: '邮件处理',
|
||||
description: '发送和管理电子邮件',
|
||||
category: '通讯',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
id: 'chat-skill',
|
||||
name: '对话技能',
|
||||
description: '自然语言对话和问答',
|
||||
category: '交互',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
id: 'automation',
|
||||
name: '自动化任务',
|
||||
description: '自动化工作流程执行',
|
||||
category: '系统',
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
id: 'tool-executor',
|
||||
name: '工具执行器',
|
||||
description: '执行系统命令和脚本',
|
||||
category: '系统',
|
||||
icon: Wrench,
|
||||
},
|
||||
];
|
||||
|
||||
export function Skills() {
|
||||
const { connectionState, quickConfig, skillsCatalog, loadSkillsCatalog, saveQuickConfig } = useGatewayStore();
|
||||
const connected = connectionState === 'connected';
|
||||
const [extraDir, setExtraDir] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | 'system' | 'builtin' | 'extra'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
@@ -23,6 +85,13 @@ export function Skills() {
|
||||
await loadSkillsCatalog();
|
||||
};
|
||||
|
||||
const filteredCatalog = skillsCatalog.filter(skill => {
|
||||
if (activeFilter === 'all') return true;
|
||||
if (activeFilter === 'builtin') return skill.source === 'builtin';
|
||||
if (activeFilter === 'extra') return skill.source === 'extra';
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -41,16 +110,47 @@ export function Skills() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 系统技能 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">ZCLAW 系统技能</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{SYSTEM_SKILLS.map((skill) => {
|
||||
const Icon = skill.icon;
|
||||
return (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{skill.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-purple-50 text-purple-600 rounded">
|
||||
{skill.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6 shadow-sm">
|
||||
<h3 className="font-medium mb-2 text-gray-900">额外技能目录</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">包含 SKILL.md 文件的额外目录。保存到 Gateway 配置的 skills.load.extraDirs 中。</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
<input
|
||||
type="text"
|
||||
value={extraDir}
|
||||
onChange={(e) => setExtraDir(e.target.value)}
|
||||
placeholder="输入额外技能目录"
|
||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none"
|
||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { handleAddDir().catch(() => {}); }}
|
||||
@@ -70,8 +170,30 @@ export function Skills() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gateway 技能 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Gateway 技能</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{['all', 'builtin', 'extra'].map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setActiveFilter(filter as typeof activeFilter)}
|
||||
className={`text-xs px-2 py-1 rounded-md transition-colors ${
|
||||
activeFilter === filter
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' ? '全部' : filter === 'builtin' ? '内置' : '额外'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm divide-y divide-gray-100">
|
||||
{skillsCatalog.length > 0 ? skillsCatalog.map((skill) => (
|
||||
{filteredCatalog.length > 0 ? filteredCatalog.map((skill) => (
|
||||
<div key={skill.id} className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useGatewayStore } from '../../store/gatewayStore';
|
||||
import { BarChart3, TrendingUp, Clock, Zap } from 'lucide-react';
|
||||
|
||||
export function UsageStats() {
|
||||
const { usageStats, loadUsageStats, connectionState } = useGatewayStore();
|
||||
const [timeRange, setTimeRange] = useState<'7d' | '30d' | 'all'>('7d');
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState === 'connected') {
|
||||
@@ -19,62 +21,154 @@ export function UsageStats() {
|
||||
return `${n}`;
|
||||
};
|
||||
|
||||
// 计算总输入和输出 Token
|
||||
const totalInputTokens = models.reduce((sum, [_, data]) => sum + data.inputTokens, 0);
|
||||
const totalOutputTokens = models.reduce((sum, [_, data]) => sum + data.outputTokens, 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">用量统计</h1>
|
||||
<button onClick={() => loadUsageStats()} className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors">
|
||||
刷新
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
||||
{(['7d', '30d', 'all'] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
timeRange === range
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{range === '7d' ? '近 7 天' : range === '30d' ? '近 30 天' : '全部'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadUsageStats()}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-4">本设备所有已保存对话的 Token 用量汇总。</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<StatCard label="会话数" value={stats.totalSessions} />
|
||||
<StatCard label="消息数" value={stats.totalMessages} />
|
||||
<StatCard label="总 Token" value={formatTokens(stats.totalTokens)} />
|
||||
{/* 主要统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
icon={BarChart3}
|
||||
label="会话数"
|
||||
value={stats.totalSessions}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Zap}
|
||||
label="消息数"
|
||||
value={stats.totalMessages}
|
||||
color="text-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label="输入 Token"
|
||||
value={formatTokens(totalInputTokens)}
|
||||
color="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="输出 Token"
|
||||
value={formatTokens(totalOutputTokens)}
|
||||
color="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 总 Token 使用量概览 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mb-6">
|
||||
<h3 className="text-sm font-semibold mb-4 text-gray-900">Token 使用概览</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>输入</span>
|
||||
<span>输出</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="bg-gradient-to-r from-green-400 to-green-500 h-full transition-all"
|
||||
style={{ width: `${(totalInputTokens / Math.max(totalInputTokens + totalOutputTokens, 1)) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-gradient-to-r from-orange-400 to-orange-500 h-full transition-all"
|
||||
style={{ width: `${(totalOutputTokens / Math.max(totalInputTokens + totalOutputTokens, 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-lg font-bold text-gray-900">{formatTokens(stats.totalTokens)}</div>
|
||||
<div className="text-xs text-gray-500">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按模型分组 */}
|
||||
<h2 className="text-sm font-semibold mb-4 text-gray-900">按模型</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm">
|
||||
{models.length === 0 && (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">暂无数据</div>
|
||||
)}
|
||||
{models.map(([model, data]) => {
|
||||
const total = data.inputTokens + data.outputTokens;
|
||||
// Scale to 100% of the bar width based on max token usage across models for relative sizing.
|
||||
// Or we can just calculate input vs output within the model. Let's do input vs output within the total.
|
||||
const inputPct = (data.inputTokens / Math.max(total, 1)) * 100;
|
||||
const outputPct = (data.outputTokens / Math.max(total, 1)) * 100;
|
||||
|
||||
return (
|
||||
<div key={model} className="p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium text-gray-900">{model}</span>
|
||||
<span className="text-xs text-gray-500">{data.messages} 条消息</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden mb-2 flex">
|
||||
<div className="bg-orange-500 h-full" style={{ width: `${inputPct}%` }} />
|
||||
<div className="bg-orange-200 h-full" style={{ width: `${outputPct}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>输入: {formatTokens(data.inputTokens)}</span>
|
||||
<span>输出: {formatTokens(data.outputTokens)}</span>
|
||||
<span>总计: {formatTokens(total)}</span>
|
||||
</div>
|
||||
{models.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<BarChart3 className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-sm text-gray-400">暂无使用数据</p>
|
||||
<p className="text-xs text-gray-300 mt-1">开始对话后将自动记录用量统计</p>
|
||||
</div>
|
||||
) : (
|
||||
models.map(([model, data]) => {
|
||||
const total = data.inputTokens + data.outputTokens;
|
||||
const inputPct = (data.inputTokens / Math.max(total, 1)) * 100;
|
||||
const outputPct = (data.outputTokens / Math.max(total, 1)) * 100;
|
||||
|
||||
return (
|
||||
<div key={model} className="p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium text-gray-900">{model}</span>
|
||||
<span className="text-xs text-gray-500">{data.messages} 条消息</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden mb-2 flex">
|
||||
<div className="bg-orange-500 h-full" style={{ width: `${inputPct}%` }} />
|
||||
<div className="bg-orange-200 h-full" style={{ width: `${outputPct}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>输入: {formatTokens(data.inputTokens)}</span>
|
||||
<span>输出: {formatTokens(data.outputTokens)}</span>
|
||||
<span>总计: {formatTokens(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string | number }) {
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: typeof BarChart3;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||
<div className="text-2xl font-bold mb-1 text-gray-900">{value}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
<span className="text-xs text-gray-500">{label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { Settings, Users, Bot, GitBranch, MessageSquare } from 'lucide-react';
|
||||
import { CloneManager } from './CloneManager';
|
||||
import { HandList } from './HandList';
|
||||
import { TaskList } from './TaskList';
|
||||
@@ -19,11 +19,11 @@ interface SidebarProps {
|
||||
|
||||
type Tab = 'clones' | 'hands' | 'workflow' | 'team';
|
||||
|
||||
const TABS: { key: Tab; label: string; mainView?: MainViewType }[] = [
|
||||
{ key: 'clones', label: '分身' },
|
||||
{ key: 'hands', label: 'HANDS', mainView: 'hands' },
|
||||
{ key: 'workflow', label: 'Workflow', mainView: 'workflow' },
|
||||
{ key: 'team', label: 'Team', mainView: 'team' },
|
||||
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' },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
@@ -54,19 +54,21 @@ export function Sidebar({
|
||||
|
||||
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">
|
||||
{/* 顶部标签 */}
|
||||
{/* 顶部标签 - 使用图标 */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
{TABS.map(({ key, label }) => (
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`flex-1 py-3 px-4 text-xs font-medium transition-colors ${
|
||||
title={label}
|
||||
className={`flex-1 py-2.5 px-2 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
|
||||
activeTab === key
|
||||
? 'text-gray-900 dark:text-white border-b-2 border-blue-500'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
onClick={() => handleTabClick(key, TABS.find(t => t.key === key)?.mainView)}
|
||||
>
|
||||
{label}
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,61 +1,97 @@
|
||||
/**
|
||||
* TriggersPanel - OpenFang Triggers Management UI
|
||||
*
|
||||
* Displays available OpenFang Triggers and allows toggling them on/off.
|
||||
* Displays available OpenFang Triggers and allows creating and toggling them.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import type { Trigger } from '../store/gatewayStore';
|
||||
import { CreateTriggerModal } from './CreateTriggerModal';
|
||||
import {
|
||||
Zap,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Globe,
|
||||
Bell,
|
||||
MessageSquare,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Trigger Type Config ===
|
||||
|
||||
const TRIGGER_TYPE_CONFIG: Record<string, { icon: typeof Zap; label: string; color: string }> = {
|
||||
webhook: { icon: Globe, label: 'Webhook', color: 'text-blue-500' },
|
||||
event: { icon: Bell, label: '事件', color: 'text-amber-500' },
|
||||
message: { icon: MessageSquare, label: '消息', color: 'text-green-500' },
|
||||
schedule: { icon: Zap, label: '定时', color: 'text-purple-500' },
|
||||
file: { icon: Zap, label: '文件', color: 'text-cyan-500' },
|
||||
manual: { icon: Zap, label: '手动', color: 'text-gray-500' },
|
||||
};
|
||||
|
||||
interface TriggerCardProps {
|
||||
trigger: Trigger;
|
||||
onToggle: (id: string, enabled: boolean) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
isToggling: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
function TriggerCard({ trigger, onToggle, isToggling }: TriggerCardProps) {
|
||||
function TriggerCard({ trigger, onToggle, onDelete, isToggling, isDeleting }: TriggerCardProps) {
|
||||
const handleToggle = async () => {
|
||||
await onToggle(trigger.id, !trigger.enabled);
|
||||
};
|
||||
|
||||
const statusColor = trigger.enabled
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-400';
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
webhook: 'Webhook',
|
||||
schedule: '定时任务',
|
||||
event: '事件触发',
|
||||
manual: '手动触发',
|
||||
file: '文件监听',
|
||||
message: '消息触发',
|
||||
const handleDelete = async () => {
|
||||
if (confirm(`确定要删除触发器 "${trigger.id}" 吗?`)) {
|
||||
await onDelete(trigger.id);
|
||||
}
|
||||
};
|
||||
|
||||
const typeConfig = TRIGGER_TYPE_CONFIG[trigger.type] || { icon: Zap, label: trigger.type, color: 'text-gray-500' };
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-sm">
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg border p-4 shadow-sm transition-colors ${
|
||||
trigger.enabled
|
||||
? 'border-green-200 dark:border-green-800'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{trigger.id}</h3>
|
||||
<span className={`w-2 h-2 rounded-full ${statusColor}`} title={trigger.enabled ? '已启用' : '已禁用'} />
|
||||
<TypeIcon className={`w-4 h-4 ${typeConfig.color}`} />
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{trigger.id}</h3>
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
trigger.enabled ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{typeLabel[trigger.type] || trigger.type}
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
<span className={`text-xs ${trigger.enabled ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{trigger.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 rounded-md disabled:opacity-50"
|
||||
title="删除"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isToggling}
|
||||
disabled={isToggling || isDeleting}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||
trigger.enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
} ${isToggling ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
} ${(isToggling || isDeleting) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={trigger.enabled ? '点击禁用' : '点击启用'}
|
||||
>
|
||||
<span
|
||||
@@ -71,9 +107,11 @@ function TriggerCard({ trigger, onToggle, isToggling }: TriggerCardProps) {
|
||||
}
|
||||
|
||||
export function TriggersPanel() {
|
||||
const { triggers, loadTriggers, isLoading, client } = useGatewayStore();
|
||||
const { triggers, loadTriggers, isLoading, client, deleteTrigger } = useGatewayStore();
|
||||
const [togglingTrigger, setTogglingTrigger] = useState<string | null>(null);
|
||||
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTriggers();
|
||||
@@ -82,9 +120,7 @@ export function TriggersPanel() {
|
||||
const handleToggle = useCallback(async (id: string, enabled: boolean) => {
|
||||
setTogglingTrigger(id);
|
||||
try {
|
||||
// Call the gateway to toggle the trigger
|
||||
await client.request('triggers.toggle', { id, enabled });
|
||||
// Reload triggers after toggle
|
||||
await loadTriggers();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle trigger:', error);
|
||||
@@ -93,6 +129,18 @@ export function TriggersPanel() {
|
||||
}
|
||||
}, [client, loadTriggers]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
setDeletingTrigger(id);
|
||||
try {
|
||||
await deleteTrigger(id);
|
||||
await loadTriggers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete trigger:', error);
|
||||
} finally {
|
||||
setDeletingTrigger(null);
|
||||
}
|
||||
}, [deleteTrigger, loadTriggers]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
@@ -102,6 +150,10 @@ export function TriggersPanel() {
|
||||
}
|
||||
}, [loadTriggers]);
|
||||
|
||||
const handleCreateSuccess = useCallback(() => {
|
||||
loadTriggers();
|
||||
}, [loadTriggers]);
|
||||
|
||||
if (isLoading && triggers.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
|
||||
@@ -110,63 +162,79 @@ export function TriggersPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
if (triggers.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
触发器 (Triggers)
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
暂无可用的触发器
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Count enabled/disabled triggers
|
||||
const enabledCount = triggers.filter(t => t.enabled).length;
|
||||
const totalCount = triggers.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
触发器 (Triggers)
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{enabledCount}/{totalCount} 已启用
|
||||
</span>
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
事件触发器
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{enabledCount}/{totalCount} 已启用
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
新建触发器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
|
||||
{triggers.length === 0 ? (
|
||||
<div className="p-8 text-center bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Zap className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">暂无事件触发器</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-4 max-w-sm mx-auto">
|
||||
事件触发器在系统事件(如收到消息、文件更改或 API webhook)发生时触发代理执行。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
创建事件触发器
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{triggers.map((trigger) => (
|
||||
<TriggerCard
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDelete}
|
||||
isToggling={togglingTrigger === trigger.id}
|
||||
isDeleting={deletingTrigger === trigger.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{triggers.map((trigger) => (
|
||||
<TriggerCard
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
onToggle={handleToggle}
|
||||
isToggling={togglingTrigger === trigger.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CreateTriggerModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user