feat(ui): Feature Gates 设置页 — 实验性功能开关 (Phase 3B)
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

新增 Settings > 实验性功能 页面:
- 3 个开关: 多 Agent 协作 / WASM 技能沙箱 / 详细工具输出
- localStorage 持久化 + isFeatureEnabled() 公共 API
- 实验性功能警告横幅
- 当前为前端运行时开关,未来可对接 Kernel config
This commit is contained in:
iven
2026-04-18 08:05:06 +08:00
parent a38e91935f
commit eaa99a20db
2 changed files with 168 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
/**
* Feature Gates — 实验性功能开关
*
* 控制前端实验性功能的可见性。当前为前端运行时开关,
* 持久化到 localStorage。未来可对接 Kernel config。
*
* 可用开关:
* - multiAgent: 多 Agent 协作模式 (Director + A2A)
* - wasmSkills: WASM 技能沙箱执行
* - verboseToolOutput: 显示完整工具输出 (调试用)
*/
import { useState, useCallback } from 'react';
import { createLogger } from '../../lib/logger';
const log = createLogger('FeatureGates');
// === Feature Flag Definitions ===
interface FeatureDef {
key: string;
label: string;
description: string;
defaultEnabled: boolean;
/** Whether this feature has Rust backend support compiled in */
backendNote?: string;
}
const FEATURES: FeatureDef[] = [
{
key: 'multiAgent',
label: '多 Agent 协作',
description: '启用 Director 编排 + A2A 协议,支持多 Agent 协作完成复杂任务',
defaultEnabled: false,
backendNote: '需要编译时启用 multi-agent feature',
},
{
key: 'wasmSkills',
label: 'WASM 技能沙箱',
description: '启用 WebAssembly 技能在沙箱中执行,支持用户自定义技能',
defaultEnabled: false,
backendNote: '需要编译时启用 wasm feature',
},
{
key: 'verboseToolOutput',
label: '详细工具输出',
description: '在聊天中显示完整的工具调用结果(默认已截断)',
defaultEnabled: false,
},
];
// === Helpers ===
const STORAGE_PREFIX = 'zclaw-feature-';
function loadFeatureState(key: string, defaultEnabled: boolean): boolean {
try {
const stored = localStorage.getItem(`${STORAGE_PREFIX}${key}`);
if (stored !== null) return stored === 'true';
} catch { /* ignore */ }
return defaultEnabled;
}
function saveFeatureState(key: string, enabled: boolean): void {
try {
localStorage.setItem(`${STORAGE_PREFIX}${key}`, String(enabled));
} catch (e) {
log.warn('Failed to persist feature flag', { key, error: e });
}
}
// === Public API ===
/** Check if a feature gate is enabled (can be called outside React) */
export function isFeatureEnabled(key: string): boolean {
const def = FEATURES.find(f => f.key === key);
return loadFeatureState(key, def?.defaultEnabled ?? false);
}
// === Component ===
export function FeatureGates() {
const [states, setStates] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
for (const f of FEATURES) {
initial[f.key] = loadFeatureState(f.key, f.defaultEnabled);
}
return initial;
});
const toggle = useCallback((key: string) => {
setStates(prev => {
const next = { ...prev, [key]: !prev[key] };
saveFeatureState(key, next[key]);
log.info(`Feature gate '${key}' set to ${next[key]}`);
return next;
});
}, []);
return (
<div className="max-w-3xl">
<div className="mb-6">
<h1 className="text-xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-500">
使
</p>
</div>
{/* Warning banner */}
<div className="mb-6 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
<span className="text-amber-600 text-lg"></span>
<div className="text-sm text-amber-800">
<p className="font-medium"></p>
<p className="mt-1"></p>
</div>
</div>
{/* Feature toggles */}
<div className="space-y-4">
{FEATURES.map(feature => (
<div
key={feature.key}
className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-gray-900">{feature.label}</h3>
{states[feature.key] && (
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
</span>
)}
</div>
<p className="mt-1 text-sm text-gray-500">{feature.description}</p>
{feature.backendNote && (
<p className="mt-1 text-xs text-gray-400">{feature.backendNote}</p>
)}
</div>
<button
onClick={() => toggle(feature.key)}
className={`
relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${states[feature.key] ? 'bg-blue-600' : 'bg-gray-200'}
`}
role="switch"
aria-checked={states[feature.key]}
aria-label={`切换 ${feature.label}`}
>
<span
className={`
pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0
transition duration-200 ease-in-out
${states[feature.key] ? 'translate-x-5' : 'translate-x-0'}
`}
/>
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -19,6 +19,7 @@ import {
Activity, Activity,
Cloud, Cloud,
CreditCard, CreditCard,
FlaskConical,
} from 'lucide-react'; } from 'lucide-react';
import { silentErrorHandler } from '../../lib/error-utils'; import { silentErrorHandler } from '../../lib/error-utils';
import { General } from './General'; import { General } from './General';
@@ -39,6 +40,7 @@ import { SecureStorage } from './SecureStorage';
import { VikingPanel } from '../VikingPanel'; import { VikingPanel } from '../VikingPanel';
import { SaaSSettings } from '../SaaS/SaaSSettings'; import { SaaSSettings } from '../SaaS/SaaSSettings';
import { PricingPage } from '../SaaS/PricingPage'; import { PricingPage } from '../SaaS/PricingPage';
import { FeatureGates } from './FeatureGates';
import { ErrorBoundary } from '../ui/ErrorBoundary'; import { ErrorBoundary } from '../ui/ErrorBoundary';
interface SettingsLayoutProps { interface SettingsLayoutProps {
@@ -62,6 +64,7 @@ type SettingsPage =
| 'tasks' | 'tasks'
| 'heartbeat' | 'heartbeat'
| 'health' | 'health'
| 'labs'
| 'feedback' | 'feedback'
| 'about'; | 'about';
@@ -85,6 +88,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode; group
{ id: 'tasks', label: '定时任务', icon: <Clock className="w-4 h-4" />, group: 'advanced' }, { id: 'tasks', label: '定时任务', icon: <Clock className="w-4 h-4" />, group: 'advanced' },
{ id: 'heartbeat', label: '心跳配置', icon: <Heart className="w-4 h-4" />, group: 'advanced' }, { id: 'heartbeat', label: '心跳配置', icon: <Heart className="w-4 h-4" />, group: 'advanced' },
{ id: 'health', label: '系统健康', icon: <Activity className="w-4 h-4" />, group: 'advanced' }, { id: 'health', label: '系统健康', icon: <Activity className="w-4 h-4" />, group: 'advanced' },
{ id: 'labs', label: '实验性功能', icon: <FlaskConical className="w-4 h-4" />, group: 'advanced' },
// --- Footer --- // --- Footer ---
{ id: 'feedback', label: '提交反馈', icon: <HelpCircle className="w-4 h-4" /> }, { id: 'feedback', label: '提交反馈', icon: <HelpCircle className="w-4 h-4" /> },
{ id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> }, { id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> },
@@ -179,6 +183,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
</div> </div>
</ErrorBoundary> </ErrorBoundary>
); );
case 'labs': return <FeatureGates />;
case 'viking': return ( case 'viking': return (
<ErrorBoundary <ErrorBoundary
fallback={<div className="p-6 text-center text-gray-500"></div>} fallback={<div className="p-6 text-center text-gray-500"></div>}