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
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:
163
desktop/src/components/Settings/FeatureGates.tsx
Normal file
163
desktop/src/components/Settings/FeatureGates.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user