Files
zclaw_openfang/desktop/src/components/AutonomyConfig.tsx
iven be01127098 fix(autonomy): hand_trigger 从 null 映射改为 handAutoTrigger 字段
根因: autonomy-manager.ts:268 将 hand_trigger 硬编码为 null,
导致任何自主权级别都无法自动触发 Hand。
新增 handAutoTrigger 字段,autonomous 级别默认 true。
UI 增加对应开关。
2026-04-11 12:32:19 +08:00

505 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AutonomyConfig - Configuration UI for L4 self-evolution authorization
*
* Allows users to configure:
* - Autonomy level (supervised/assisted/autonomous)
* - Individual action permissions
* - Approval thresholds
* - Audit log viewing
*
* Part of ZCLAW L4 Self-Evolution capability.
*/
import { useState, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Shield,
ShieldAlert,
ShieldCheck,
ShieldQuestion,
Settings,
AlertTriangle,
CheckCircle,
Clock,
RotateCcw,
Info,
ChevronDown,
ChevronRight,
Trash2,
} from 'lucide-react';
import {
getAutonomyManager,
DEFAULT_AUTONOMY_CONFIGS,
type AutonomyConfig,
type AutonomyLevel,
type AuditLogEntry,
type ActionType,
} from '../lib/autonomy-manager';
// === Types ===
interface AutonomyConfigProps {
className?: string;
onConfigChange?: (config: AutonomyConfig) => void;
}
// === Autonomy Level Config ===
const LEVEL_CONFIG: Record<AutonomyLevel, {
label: string;
description: string;
icon: typeof Shield;
color: string;
}> = {
supervised: {
label: '监督模式',
description: '所有操作都需要用户确认',
icon: ShieldQuestion,
color: 'text-yellow-500',
},
assisted: {
label: '辅助模式',
description: '低风险操作自动执行,高风险需确认',
icon: ShieldAlert,
color: 'text-blue-500',
},
autonomous: {
label: '自主模式',
description: 'Agent 自主决策,仅高影响操作通知',
icon: ShieldCheck,
color: 'text-green-500',
},
};
const ACTION_LABELS: Record<ActionType, string> = {
memory_save: '自动保存记忆',
memory_delete: '删除记忆',
identity_update: '更新身份文件',
identity_rollback: '回滚身份',
skill_install: '安装技能',
skill_uninstall: '卸载技能',
config_change: '修改配置',
workflow_trigger: '触发工作流',
hand_trigger: '触发 Hand',
llm_call: '调用 LLM',
reflection_run: '运行反思',
compaction_run: '运行压缩',
};
// === Components ===
function LevelSelector({
value,
onChange,
}: {
value: AutonomyLevel;
onChange: (level: AutonomyLevel) => void;
}) {
return (
<div className="space-y-2">
{(Object.keys(LEVEL_CONFIG) as AutonomyLevel[]).map((level) => {
const config = LEVEL_CONFIG[level];
const Icon = config.icon;
const isSelected = value === level;
return (
<button
key={level}
onClick={() => onChange(level)}
className={`w-full flex items-start gap-3 p-3 rounded-lg border transition-all text-left ${
isSelected
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<Icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${config.color}`} />
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium ${
isSelected ? 'text-purple-700 dark:text-purple-400' : 'text-gray-700 dark:text-gray-300'
}`}>
{config.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{config.description}
</div>
</div>
{isSelected && (
<CheckCircle className="w-4 h-4 text-purple-500 flex-shrink-0" />
)}
</button>
);
})}
</div>
);
}
function ActionToggle({
label,
enabled,
onChange,
disabled,
}: {
label: string;
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}) {
return (
<div className={`flex items-center justify-between py-2 ${disabled ? 'opacity-50' : ''}`}>
<span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
<button
onClick={() => !disabled && onChange(!enabled)}
disabled={disabled}
className={`relative w-9 h-5 rounded-full transition-colors ${
enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'cursor-not-allowed' : ''}`}
>
<motion.div
animate={{ x: enabled ? 18 : 0 }}
className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow"
/>
</button>
</div>
);
}
function AuditLogEntryItem({
entry,
onRollback,
}: {
entry: AuditLogEntry;
onRollback?: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const outcomeColors = {
success: 'text-green-500',
failed: 'text-red-500',
rolled_back: 'text-yellow-500',
};
const outcomeLabels = {
success: '成功',
failed: '失败',
rolled_back: '已回滚',
};
const time = new Date(entry.timestamp).toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
return (
<div className="border-b border-gray-100 dark:border-gray-800 last:border-b-0">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 py-2 px-1 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3 h-3 text-gray-400" />
) : (
<ChevronRight className="w-3 h-3 text-gray-400" />
)}
<span className="text-xs text-gray-400">{time}</span>
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 text-left truncate">
{ACTION_LABELS[entry.action] || entry.action}
</span>
<span className={`text-xs ${outcomeColors[entry.outcome]}`}>
{outcomeLabels[entry.outcome]}
</span>
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-6 pb-2 space-y-1"
>
<div className="text-xs text-gray-500 dark:text-gray-400">
: {entry.decision.riskLevel} · : {entry.decision.importance}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
: {entry.decision.reason}
</div>
{entry.outcome !== 'rolled_back' && entry.decision.riskLevel !== 'low' && (
<button
onClick={() => onRollback?.(entry.id)}
className="flex items-center gap-1 text-xs text-yellow-600 dark:text-yellow-400 hover:underline mt-1"
>
<RotateCcw className="w-3 h-3" />
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// === Main Component ===
export function AutonomyConfig({ className = '', onConfigChange }: AutonomyConfigProps) {
const [manager] = useState(() => getAutonomyManager());
const [config, setConfig] = useState<AutonomyConfig>(manager.getConfig());
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Load audit log
useEffect(() => {
setAuditLog(manager.getAuditLog(50));
}, [manager]);
const updateConfig = useCallback(
(updates: Partial<AutonomyConfig>) => {
setConfig((prev) => {
const next = { ...prev, ...updates };
setHasChanges(true);
onConfigChange?.(next);
return next;
});
},
[onConfigChange]
);
const handleLevelChange = useCallback((level: AutonomyLevel) => {
const newConfig = DEFAULT_AUTONOMY_CONFIGS[level];
setConfig(newConfig);
setHasChanges(true);
onConfigChange?.(newConfig);
}, [onConfigChange]);
const handleSave = useCallback(() => {
manager.updateConfig(config);
setHasChanges(false);
}, [manager, config]);
const handleRollback = useCallback((auditId: string) => {
if (manager.rollback(auditId)) {
setAuditLog(manager.getAuditLog(50));
}
}, [manager]);
const handleClearLog = useCallback(() => {
manager.clearAuditLog();
setAuditLog([]);
}, [manager]);
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-purple-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
</div>
<button
onClick={handleSave}
disabled={!hasChanges}
className="px-3 py-1.5 text-sm bg-purple-500 hover:bg-purple-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Autonomy Level */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
</span>
</div>
<LevelSelector value={config.level} onChange={handleLevelChange} />
</div>
{/* Allowed Actions */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
</span>
</div>
<div className="pl-6 space-y-1 border-l-2 border-gray-200 dark:border-gray-700">
<ActionToggle
label="自动保存记忆"
enabled={config.allowedActions.memoryAutoSave}
onChange={(enabled) =>
updateConfig({
allowedActions: { ...config.allowedActions, memoryAutoSave: enabled },
})
}
/>
<ActionToggle
label="自动更新身份文件"
enabled={config.allowedActions.identityAutoUpdate}
onChange={(enabled) =>
updateConfig({
allowedActions: { ...config.allowedActions, identityAutoUpdate: enabled },
})
}
/>
<ActionToggle
label="自动安装技能"
enabled={config.allowedActions.skillAutoInstall}
onChange={(enabled) =>
updateConfig({
allowedActions: { ...config.allowedActions, skillAutoInstall: enabled },
})
}
/>
<ActionToggle
label="自我修改行为"
enabled={config.allowedActions.selfModification}
onChange={(enabled) =>
updateConfig({
allowedActions: { ...config.allowedActions, selfModification: enabled },
})
}
/>
<ActionToggle
label="自动上下文压缩"
enabled={config.allowedActions.autoCompaction}
onChange={(enabled) =>
updateConfig({
allowedActions: { ...config.allowedActions, autoCompaction: enabled },
})
}
/>
<ActionToggle
label="自动反思"
enabled={config.allowedActions.autoReflection}
onChange={(enabled) =>
updateConfig({
allowedActions: { ...config.allowedActions, autoReflection: enabled },
})
}
/>
<ActionToggle
label="自动触发 Hand 能力"
enabled={config.allowedActions.handAutoTrigger}
onChange={(enabled) =>
updateConfig({
allowedActions: { ...config.allowedActions, handAutoTrigger: enabled },
})
}
/>
</div>
</div>
{/* Approval Thresholds */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
</span>
</div>
<div className="pl-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">
</span>
<input
type="range"
min="0"
max="10"
value={config.approvalThreshold.importanceMax}
onChange={(e) =>
updateConfig({
approvalThreshold: {
...config.approvalThreshold,
importanceMax: parseInt(e.target.value),
},
})
}
className="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500"
/>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 w-6 text-right">
{config.approvalThreshold.importanceMax}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">
</span>
<select
value={config.approvalThreshold.riskMax}
onChange={(e) =>
updateConfig({
approvalThreshold: {
...config.approvalThreshold,
riskMax: e.target.value as 'low' | 'medium' | 'high',
},
})
}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
</select>
</div>
</div>
</div>
{/* Audit Log */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
</span>
<span className="text-xs text-gray-400">({auditLog.length} )</span>
</div>
<button
onClick={handleClearLog}
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
title="清除日志"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{auditLog.length > 0 ? (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden max-h-64 overflow-y-auto">
{auditLog
.slice()
.reverse()
.map((entry) => (
<AuditLogEntryItem
key={entry.id}
entry={entry}
onRollback={handleRollback}
/>
))}
</div>
) : (
<div className="text-center py-8 text-gray-400 dark:text-gray-500 text-sm">
</div>
)}
</div>
{/* Info */}
<div className="flex items-start gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-xs text-yellow-600 dark:text-yellow-400">
<Info className="w-4 h-4 flex-shrink-0 mt-0.5" />
<p>
</p>
</div>
</div>
</div>
);
}
export default AutonomyConfig;