feat(hands): restructure Hands UI with Chinese localization

Major changes:
- Add HandList.tsx component for left sidebar
- Add HandTaskPanel.tsx for middle content area
- Restructure Sidebar tabs: 分身/HANDS/Workflow
- Remove Hands tab from RightPanel
- Localize all UI text to Chinese
- Archive legacy OpenClaw documentation
- Add Hands integration lessons document
- Update feature checklist with new components

UI improvements:
- Left sidebar now shows Hands list with status icons
- Middle area shows selected Hand's tasks and results
- Consistent styling with Tailwind CSS
- Chinese status labels and buttons

Documentation:
- Create docs/archive/openclaw-legacy/ for old docs
- Add docs/knowledge-base/hands-integration-lessons.md
- Update docs/knowledge-base/feature-checklist.md
- Update docs/knowledge-base/README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-14 23:16:32 +08:00
parent 67e1da635d
commit 07079293f4
126 changed files with 36229 additions and 1035 deletions

View File

@@ -1,28 +1,42 @@
import { useState, useEffect } from 'react';
import './index.css';
import { Sidebar } from './components/Sidebar';
import { Sidebar, MainViewType } from './components/Sidebar';
import { ChatArea } from './components/ChatArea';
import { RightPanel } from './components/RightPanel';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { HandTaskPanel } from './components/HandTaskPanel';
import { WorkflowList } from './components/WorkflowList';
import { TriggersPanel } from './components/TriggersPanel';
import { useGatewayStore } from './store/gatewayStore';
import { getStoredGatewayToken } from './lib/gateway-client';
type View = 'main' | 'settings';
function App() {
const [view, setView] = useState<View>('main');
const [mainContentView, setMainContentView] = useState<MainViewType>('chat');
const [selectedHandId, setSelectedHandId] = useState<string | undefined>(undefined);
const { connect, connectionState } = useGatewayStore();
// Auto-connect to Gateway on startup
useEffect(() => {
document.title = 'ZCLAW';
}, []);
useEffect(() => {
if (connectionState === 'disconnected') {
// Try default port 18789 first, then fallback to 18790
connect('ws://127.0.0.1:18789').catch(() => {
connect('ws://127.0.0.1:18790').catch(() => {
// Silent fail — user can manually connect via Settings
});
});
const gatewayToken = getStoredGatewayToken();
connect(undefined, gatewayToken).catch(() => {});
}
}, []);
}, [connect, connectionState]);
// 当切换到非 hands 视图时清除选中的 Hand
const handleMainViewChange = (view: MainViewType) => {
setMainContentView(view);
if (view !== 'hands') {
// 可选:清除选中的 Hand
// setSelectedHandId(undefined);
}
};
if (view === 'settings') {
return <SettingsLayout onBack={() => setView('main')} />;
@@ -31,13 +45,42 @@ function App() {
return (
<div className="h-screen flex overflow-hidden text-gray-800 text-sm">
{/* 左侧边栏 */}
<Sidebar onOpenSettings={() => setView('settings')} />
{/* 中间对话区域 */}
<main className="flex-1 flex flex-col bg-white relative">
<ChatArea />
<Sidebar
onOpenSettings={() => setView('settings')}
onMainViewChange={handleMainViewChange}
selectedHandId={selectedHandId}
onSelectHand={setSelectedHandId}
/>
{/* 中间区域 */}
<main className="flex-1 flex flex-col bg-white relative overflow-hidden">
{mainContentView === 'hands' && selectedHandId ? (
<HandTaskPanel
handId={selectedHandId}
onBack={() => setSelectedHandId(undefined)}
/>
) : mainContentView === 'hands' ? (
<div className="flex-1 flex items-center justify-center p-6">
<div className="text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-4xl">🤖</span>
</div>
<h3 className="text-lg font-semibold text-gray-700 mb-2"> Hand</h3>
<p className="text-sm text-gray-400 max-w-sm">
</p>
</div>
</div>
) : mainContentView === 'workflow' ? (
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<WorkflowList />
<TriggersPanel />
</div>
) : (
<ChatArea />
)}
</main>
{/* 右侧边栏 */}
<RightPanel />
</div>
@@ -45,3 +88,5 @@ function App() {
}
export default App;

View File

@@ -0,0 +1,419 @@
/**
* ApprovalsPanel - OpenFang Execution Approvals UI
*
* Displays pending, approved, and rejected approval requests
* for Hand executions that require human approval.
*
* Design based on OpenFang Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import {
useGatewayStore,
type Approval,
type ApprovalStatus,
} from '../store/gatewayStore';
import {
CheckCircle,
XCircle,
Clock,
RefreshCw,
AlertCircle,
Loader2,
ChevronRight,
} from 'lucide-react';
// === Status Badge Component ===
type FilterStatus = 'all' | ApprovalStatus;
interface StatusFilterConfig {
label: string;
className: string;
}
const STATUS_FILTER_CONFIG: Record<FilterStatus, StatusFilterConfig> = {
all: {
label: '全部',
className:
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
},
pending: {
label: '待审批',
className:
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
},
approved: {
label: '已批准',
className:
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
rejected: {
label: '已拒绝',
className:
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
},
expired: {
label: '已过期',
className:
'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
},
};
function StatusFilterButton({
status,
isActive,
count,
onClick,
}: {
status: FilterStatus;
isActive: boolean;
count?: number;
onClick: () => void;
}) {
const config = STATUS_FILTER_CONFIG[status];
return (
<button
onClick={onClick}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
isActive
? config.className
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{config.label}
{count !== undefined && count > 0 && (
<span className="ml-1.5 text-xs opacity-75">({count})</span>
)}
</button>
);
}
// === Approval Status Icon ===
function ApprovalStatusIcon({ status }: { status: ApprovalStatus }) {
switch (status) {
case 'pending':
return <Clock className="w-4 h-4 text-yellow-500" />;
case 'approved':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'rejected':
return <XCircle className="w-4 h-4 text-red-500" />;
case 'expired':
return <AlertCircle className="w-4 h-4 text-gray-400" />;
default:
return null;
}
}
// === Approval Card Component ===
interface ApprovalCardProps {
approval: Approval;
onApprove: (id: string) => void;
onReject: (id: string, reason: string) => void;
isProcessing: boolean;
}
function ApprovalCard({
approval,
onApprove,
onReject,
isProcessing,
}: ApprovalCardProps) {
const [showRejectInput, setShowRejectInput] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const isPending = approval.status === 'pending';
const handleReject = () => {
if (showRejectInput && rejectReason.trim()) {
onReject(approval.id, rejectReason.trim());
setRejectReason('');
setShowRejectInput(false);
} else {
setShowRejectInput(true);
}
};
const handleCancelReject = () => {
setShowRejectInput(false);
setRejectReason('');
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-sm">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<ApprovalStatusIcon status={approval.status} />
<div className="min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">
{approval.handName}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{approval.action || '执行'} {' '}
{new Date(approval.requestedAt).toLocaleString()}
</p>
</div>
</div>
<span
className={`flex-shrink-0 px-2 py-0.5 rounded text-xs font-medium ${
STATUS_FILTER_CONFIG[approval.status]?.className ||
STATUS_FILTER_CONFIG.pending.className
}`}
>
{approval.status}
</span>
</div>
{/* Reason */}
{approval.reason && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{approval.reason}
</p>
)}
{/* Params Preview */}
{approval.params && Object.keys(approval.params).length > 0 && (
<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-900 rounded text-xs font-mono text-gray-600 dark:text-gray-400 overflow-x-auto">
<pre>{JSON.stringify(approval.params, null, 2)}</pre>
</div>
)}
{/* Response Info (if responded) */}
{approval.status !== 'pending' && approval.respondedAt && (
<div className="mb-3 text-xs text-gray-500 dark:text-gray-400">
<p>
: {new Date(approval.respondedAt).toLocaleString()}
{approval.respondedBy && `${approval.respondedBy}`}
</p>
{approval.responseReason && (
<p className="mt-1 italic">"{approval.responseReason}"</p>
)}
</div>
)}
{/* Reject Input */}
{showRejectInput && (
<div className="mb-3 space-y-2">
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="请输入拒绝原因..."
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={2}
autoFocus
/>
<div className="flex gap-2">
<button
onClick={handleCancelReject}
className="px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
</button>
<button
onClick={handleReject}
disabled={!rejectReason.trim() || isProcessing}
className="px-3 py-1 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
)}
{/* Actions */}
{isPending && !showRejectInput && (
<div className="flex items-center gap-2">
<button
onClick={() => onApprove(approval.id)}
disabled={isProcessing}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
{isProcessing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCircle className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={handleReject}
disabled={isProcessing}
className="px-3 py-1.5 text-sm border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
<XCircle className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
// === Empty State Component ===
function EmptyState({ filter }: { filter: FilterStatus }) {
const messages: Record<FilterStatus, { title: string; description: string }> = {
all: {
title: '暂无审批请求',
description:
'当代理请求执行敏感操作时,审批请求将显示在这里。',
},
pending: {
title: '暂无待审批请求',
description: '所有审批请求已处理完成。',
},
approved: {
title: '暂无已批准请求',
description: '还没有批准任何请求。',
},
rejected: {
title: '暂无已拒绝请求',
description: '还没有拒绝任何请求。',
},
expired: {
title: '暂无已过期请求',
description: '没有过期的审批请求。',
},
};
const { title, description } = messages[filter];
return (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">
{title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
{description}
</p>
</div>
);
}
// === Main ApprovalsPanel Component ===
export function ApprovalsPanel() {
const { approvals, loadApprovals, respondToApproval, isLoading } =
useGatewayStore();
const [filter, setFilter] = useState<FilterStatus>('all');
const [processingId, setProcessingId] = useState<string | null>(null);
useEffect(() => {
loadApprovals();
}, [loadApprovals]);
const handleApprove = useCallback(
async (id: string) => {
setProcessingId(id);
try {
await respondToApproval(id, true);
} finally {
setProcessingId(null);
}
},
[respondToApproval]
);
const handleReject = useCallback(
async (id: string, reason: string) => {
setProcessingId(id);
try {
await respondToApproval(id, false, reason);
} finally {
setProcessingId(null);
}
},
[respondToApproval]
);
// Filter approvals
const filteredApprovals =
filter === 'all'
? approvals
: approvals.filter((a) => a.status === filter);
// Count by status
const counts = {
all: approvals.length,
pending: approvals.filter((a) => a.status === 'pending').length,
approved: approvals.filter((a) => a.status === 'approved').length,
rejected: approvals.filter((a) => a.status === 'rejected').length,
expired: approvals.filter((a) => a.status === 'expired').length,
};
if (isLoading && approvals.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
...
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
Hand
</p>
</div>
<button
onClick={() => loadApprovals()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Filters */}
<div className="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
{(Object.keys(STATUS_FILTER_CONFIG) as FilterStatus[]).map((status) => (
<StatusFilterButton
key={status}
status={status}
isActive={filter === status}
count={counts[status]}
onClick={() => setFilter(status)}
/>
))}
</div>
{/* Approvals List */}
{filteredApprovals.length === 0 ? (
<EmptyState filter={filter} />
) : (
<div className="space-y-3">
{filteredApprovals.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
onApprove={handleApprove}
onReject={handleReject}
isProcessing={processingId === approval.id}
/>
))}
</div>
)}
</div>
);
}
export default ApprovalsPanel;

View File

@@ -0,0 +1,108 @@
/**
* AuditLogsPanel - OpenFang Audit Logs UI
*
* Displays OpenFang's Merkle hash chain audit logs.
*/
import { useState, useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
export function AuditLogsPanel() {
const { auditLogs, loadAuditLogs, isLoading } = useGatewayStore();
const [limit, setLimit] = useState(50);
useEffect(() => {
loadAuditLogs({ limit });
}, [loadAuditLogs, limit]);
const formatTimestamp = (timestamp: string) => {
try {
return new Date(timestamp).toLocaleString('zh-CN');
} catch {
return timestamp;
}
};
const resultColor = {
success: 'text-green-600 dark:text-green-400',
failure: 'text-red-600 dark:text-red-400',
};
if (isLoading && auditLogs.length === 0) {
return (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
...
</div>
);
}
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">
</h2>
<div className="flex items-center gap-2">
<select
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
className="text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1"
>
<option value={25}>25 </option>
<option value={50}>50 </option>
<option value={100}>100 </option>
<option value={200}>200 </option>
</select>
<button
onClick={() => loadAuditLogs({ limit })}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
</button>
</div>
</div>
{auditLogs.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300"></th>
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300"></th>
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300"></th>
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300"></th>
</tr>
</thead>
<tbody>
{auditLogs.map((log, index) => (
<tr
key={log.id || index}
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<td className="py-2 px-3 text-gray-600 dark:text-gray-400">
{formatTimestamp(log.timestamp)}
</td>
<td className="py-2 px-3 text-gray-900 dark:text-white">
{log.action}
</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-400">
{log.actor || '-'}
</td>
<td className={`py-2 px-3 ${log.result ? resultColor[log.result] : 'text-gray-600 dark:text-gray-400'}`}>
{log.result === 'success' ? '成功' : log.result === 'failure' ? '失败' : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export default AuditLogsPanel;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useChatStore, Message } from '../store/chatStore';
import { useGatewayStore } from '../store/gatewayStore';
import { Send, Paperclip, ChevronDown, Terminal, Loader2, SquarePen } from 'lucide-react';
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp } from 'lucide-react';
const MODELS = ['glm-5', 'qwen3.5-plus', 'kimi-k2.5', 'minimax-m2.5'];
@@ -41,7 +41,7 @@ export function ChatArea() {
}, [messages]);
const handleSend = () => {
if (!input.trim() || isStreaming) return;
if (!input.trim() || isStreaming || !connected) return;
sendToGateway(input);
setInput('');
};
@@ -58,13 +58,20 @@ export function ChatArea() {
return (
<>
{/* Header */}
<div className="h-14 border-b border-gray-100 flex items-center justify-between px-6 flex-shrink-0">
<div className="h-14 border-b border-gray-100 flex items-center justify-between px-6 flex-shrink-0 bg-white">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-gray-900">{currentAgent?.name || 'ZCLAW'}</h2>
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-500' : 'text-gray-400'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300'}`}></span>
{connected ? 'Gateway 已连接' : 'Gateway 未连接'}
</span>
{isStreaming ? (
<span className="text-xs text-gray-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full thinking-dot"></span>
</span>
) : (
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-500' : 'text-gray-400'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300'}`}></span>
{connected ? 'Gateway 已连接' : 'Gateway 未连接'}
</span>
)}
</div>
<div className="flex items-center gap-2">
{messages.length > 0 && (
@@ -81,7 +88,7 @@ export function ChatArea() {
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
{messages.length === 0 && (
<div className="text-center text-gray-400 py-20">
<p className="text-lg mb-2">使 ZCLAW 🦞</p>
@@ -92,13 +99,6 @@ export function ChatArea() {
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isStreaming && (
<div className="flex items-center gap-2 text-gray-400 text-xs pl-12">
<Loader2 className="w-3 h-3 animate-spin" />
Agent ...
</div>
)}
</div>
{/* Input */}
@@ -114,10 +114,16 @@ export function ChatArea() {
value={input}
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
onKeyDown={handleKeyDown}
placeholder={isStreaming ? 'Agent 正在回复...' : '发送给 ZCLAWShift+Enter 换行)'}
disabled={isStreaming}
placeholder={
!connected
? '请先连接 Gateway'
: isStreaming
? 'Agent 正在回复...'
: `发送给 ${currentAgent?.name || 'ZCLAW'}`
}
disabled={isStreaming || !connected}
rows={1}
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400 disabled:opacity-50 resize-none leading-relaxed"
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400 disabled:opacity-50 resize-none leading-relaxed mt-1"
style={{ minHeight: '24px', maxHeight: '160px' }}
/>
</div>
@@ -144,10 +150,10 @@ export function ChatArea() {
)}
<button
onClick={handleSend}
disabled={isStreaming || !input.trim()}
disabled={isStreaming || !input.trim() || !connected}
className="w-8 h-8 bg-gray-900 text-white rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
<ArrowUp className="w-4 h-4" />
</button>
</div>
</div>

View File

@@ -1,19 +1,51 @@
import { useState, useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { useChatStore } from '../store/chatStore';
import { Plus, Trash2, Bot, X } from 'lucide-react';
import { toChatAgent, useChatStore } from '../store/chatStore';
import { Bot, Plus, X, Globe, Cat, Search, BarChart2 } from 'lucide-react';
interface CloneFormData {
name: string;
role: string;
nickname: string;
scenarios: string;
workspaceDir: string;
userName: string;
userRole: string;
restrictFiles: boolean;
privacyOptIn: boolean;
}
const DEFAULT_WORKSPACE = '~/.openclaw/zclaw-workspace';
function createFormFromDraft(quickConfig: {
agentName?: string;
agentRole?: string;
agentNickname?: string;
scenarios?: string[];
workspaceDir?: string;
userName?: string;
userRole?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
}): CloneFormData {
return {
name: quickConfig.agentName || '',
role: quickConfig.agentRole || '',
nickname: quickConfig.agentNickname || '',
scenarios: quickConfig.scenarios?.join(', ') || '',
workspaceDir: quickConfig.workspaceDir || DEFAULT_WORKSPACE,
userName: quickConfig.userName || '',
userRole: quickConfig.userRole || '',
restrictFiles: quickConfig.restrictFiles ?? true,
privacyOptIn: quickConfig.privacyOptIn ?? false,
};
}
export function CloneManager() {
const { clones, loadClones, createClone, deleteClone, connectionState } = useGatewayStore();
const { agents } = useChatStore();
const { clones, loadClones, createClone, deleteClone, connectionState, quickConfig, saveQuickConfig } = useGatewayStore();
const { agents, currentAgent, setCurrentAgent } = useChatStore();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<CloneFormData>({ name: '', role: '', scenarios: '' });
const [form, setForm] = useState<CloneFormData>(createFormFromDraft({}));
const connected = connectionState === 'connected';
@@ -23,14 +55,54 @@ export function CloneManager() {
}
}, [connected]);
useEffect(() => {
if (showForm) {
setForm(createFormFromDraft(quickConfig));
}
}, [showForm, quickConfig]);
const handleCreate = async () => {
if (!form.name.trim()) return;
await createClone({
const scenarios = form.scenarios
? form.scenarios.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
await saveQuickConfig({
agentName: form.name,
agentRole: form.role || undefined,
agentNickname: form.nickname || undefined,
scenarios,
workspaceDir: form.workspaceDir || undefined,
userName: form.userName || undefined,
userRole: form.userRole || undefined,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
});
const clone = await createClone({
name: form.name,
role: form.role || undefined,
scenarios: form.scenarios ? form.scenarios.split(',').map(s => s.trim()) : undefined,
nickname: form.nickname || undefined,
scenarios,
workspaceDir: form.workspaceDir || undefined,
userName: form.userName || undefined,
userRole: form.userRole || undefined,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
});
setForm({ name: '', role: '', scenarios: '' });
if (clone) {
setCurrentAgent(toChatAgent(clone));
}
setForm(createFormFromDraft({
...quickConfig,
agentName: form.name,
agentRole: form.role,
agentNickname: form.nickname,
scenarios,
workspaceDir: form.workspaceDir,
userName: form.userName,
userRole: form.userRole,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
}));
setShowForm(false);
};
@@ -45,28 +117,40 @@ export function CloneManager() {
id: a.id,
name: a.name,
role: '默认助手',
nickname: a.name,
scenarios: [],
workspaceDir: '~/.openclaw/zclaw-workspace',
userName: quickConfig.userName || '未设置',
userRole: '',
restrictFiles: true,
privacyOptIn: false,
createdAt: '',
}));
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-xs font-medium text-gray-500"></span>
<button
onClick={() => setShowForm(true)}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="创建分身"
>
<Plus className="w-4 h-4" />
</button>
</div>
// Function to assign pseudo icons/colors based on names for UI matching
const getIconAndColor = (name: string) => {
if (name.includes('Browser') || name.includes('浏览器')) {
return { icon: <Globe className="w-5 h-5" />, bg: 'bg-blue-500 text-white' };
}
if (name.includes('AutoClaw') || name.includes('ZCLAW')) {
return { icon: <Cat className="w-6 h-6" />, bg: 'bg-gradient-to-br from-orange-400 to-red-500 text-white' };
}
if (name.includes('沉思')) {
return { icon: <Search className="w-5 h-5" />, bg: 'bg-blue-100 text-blue-600' };
}
if (name.includes('监控')) {
return { icon: <BarChart2 className="w-5 h-5" />, bg: 'bg-orange-100 text-orange-600' };
}
return { icon: <Bot className="w-5 h-5" />, bg: 'bg-gray-200 text-gray-600' };
};
return (
<div className="h-full flex flex-col py-2">
{/* Create form */}
{showForm && (
<div className="p-3 border-b border-gray-200 bg-orange-50 space-y-2">
<div className="mx-2 mb-2 p-3 border border-gray-200 rounded-lg bg-white space-y-2 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-orange-700"></span>
<span className="text-xs font-medium text-gray-900"> Agent</span>
<button onClick={() => setShowForm(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-3.5 h-3.5" />
</button>
@@ -76,61 +160,134 @@ export function CloneManager() {
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
placeholder="名称 (必填)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.role}
onChange={e => setForm({ ...form, role: e.target.value })}
placeholder="角色 (如: 代码助手)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.nickname}
onChange={e => setForm({ ...form, nickname: e.target.value })}
placeholder="昵称 / 对你的称呼"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.scenarios}
onChange={e => setForm({ ...form, scenarios: e.target.value })}
placeholder="场景标签 (逗号分隔)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-orange-500"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.workspaceDir}
onChange={e => setForm({ ...form, workspaceDir: e.target.value })}
placeholder="工作目录"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={form.userName}
onChange={e => setForm({ ...form, userName: e.target.value })}
placeholder="你的名字"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.userRole}
onChange={e => setForm({ ...form, userRole: e.target.value })}
placeholder="你的角色"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
</div>
<label className="flex items-center justify-between text-xs text-gray-600 px-1">
<span>访</span>
<input
type="checkbox"
checked={form.restrictFiles}
onChange={e => setForm({ ...form, restrictFiles: e.target.checked })}
/>
</label>
<label className="flex items-center justify-between text-xs text-gray-600 px-1">
<span></span>
<input
type="checkbox"
checked={form.privacyOptIn}
onChange={e => setForm({ ...form, privacyOptIn: e.target.checked })}
/>
</label>
<button
onClick={handleCreate}
disabled={!form.name.trim()}
className="w-full text-xs bg-orange-500 text-white rounded py-1.5 hover:bg-orange-600 disabled:opacity-50"
className="w-full text-xs bg-gray-900 text-white rounded py-1.5 hover:bg-gray-800 disabled:opacity-50"
>
</button>
</div>
)}
{/* Clone list */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{displayClones.map((clone) => (
<div
key={clone.id}
className="group flex items-center gap-3 px-3 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-50"
>
<div className="w-9 h-9 bg-gradient-to-br from-orange-400 to-red-500 rounded-xl flex items-center justify-center text-white flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{clone.name}</div>
<div className="text-xs text-gray-400 truncate">{clone.role || '默认助手'}</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(clone.id); }}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-300 hover:text-red-500 transition-opacity"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
{displayClones.map((clone, idx) => {
const { icon, bg } = getIconAndColor(clone.name);
const isActive = currentAgent ? currentAgent.id === clone.id : idx === 0;
const canDelete = clones.length > 0;
{displayClones.length === 0 && (
<div className="text-center py-8 text-xs text-gray-400">
<Bot className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p></p>
<p className="mt-1"> + </p>
return (
<div
key={clone.id}
onClick={() => setCurrentAgent(toChatAgent(clone))}
className={`group sidebar-item mx-2 px-3 py-3 rounded-lg cursor-pointer mb-1 flex items-start gap-3 transition-colors ${
isActive ? 'bg-white shadow-sm border border-gray-100' : 'hover:bg-black/5'
}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${bg}`}>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-0.5">
<span className={`truncate ${isActive ? 'font-semibold text-gray-900' : 'font-medium text-gray-900'}`}>{clone.name}</span>
{isActive ? <span className="text-xs text-orange-500"></span> : null}
</div>
<p className="text-xs text-gray-500 truncate">{clone.role || '新分身'}</p>
</div>
{canDelete && (
<button
onClick={(e) => { e.stopPropagation(); handleDelete(clone.id); }}
className="pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 p-1 mt-1 text-gray-300 hover:text-red-500 transition-opacity"
title="删除"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
{/* Add new clone button as an item if we want, or keep the traditional way */}
{!showForm && (
<div
onClick={() => {
if (connected) {
setShowForm(true);
}
}}
className={`sidebar-item mx-2 px-3 py-3 rounded-lg mb-1 flex items-center gap-3 transition-colors border border-dashed border-gray-300 ${
connected
? 'cursor-pointer text-gray-500 hover:text-gray-900 hover:bg-black/5'
: 'cursor-not-allowed text-gray-400 bg-gray-50'
}`}
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 bg-gray-50">
<Plus className="w-5 h-5" />
</div>
<span className="text-sm font-medium">{connected ? '快速配置新 Agent' : '连接 Gateway 后创建'}</span>
</div>
)}
</div>

View File

@@ -3,7 +3,7 @@ import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
export function ConversationList() {
const {
conversations, currentConversationId, messages,
conversations, currentConversationId, messages, agents, currentAgent,
newConversation, switchConversation, deleteConversation,
} = useChatStore();
@@ -33,7 +33,7 @@ export function ConversationList() {
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-orange-700 truncate"></div>
<div className="text-[11px] text-orange-500 truncate">
{messages.filter(m => m.role === 'user').length}
{messages.filter(m => m.role === 'user').length} · {currentAgent?.name || 'ZCLAW'}
</div>
</div>
</div>
@@ -44,6 +44,9 @@ export function ConversationList() {
const isActive = conv.id === currentConversationId;
const msgCount = conv.messages.filter(m => m.role === 'user').length;
const timeStr = formatTime(conv.updatedAt);
const agentName = conv.agentId
? agents.find((agent) => agent.id === conv.agentId)?.name || conv.agentId
: 'ZCLAW';
return (
<div
@@ -63,7 +66,7 @@ export function ConversationList() {
{conv.title}
</div>
<div className="text-[11px] text-gray-400 truncate">
{msgCount} · {timeStr}
{msgCount} · {agentName} · {timeStr}
</div>
</div>
<button

View File

@@ -0,0 +1,129 @@
/**
* HandList - 左侧导航的 Hands 列表
*
* 显示所有可用的 Hands自主能力包
* 允许用户选择一个 Hand 来查看其任务和结果。
*/
import { useEffect } from 'react';
import { useGatewayStore, type Hand } from '../store/gatewayStore';
import { Zap, Loader2, RefreshCw, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
interface HandListProps {
selectedHandId?: string;
onSelectHand?: (handId: string) => void;
}
// 状态图标
function HandStatusIcon({ status }: { status: Hand['status'] }) {
switch (status) {
case 'running':
return <Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin" />;
case 'needs_approval':
return <AlertTriangle className="w-3.5 h-3.5 text-yellow-500" />;
case 'error':
return <XCircle className="w-3.5 h-3.5 text-red-500" />;
case 'setup_needed':
case 'unavailable':
return <AlertTriangle className="w-3.5 h-3.5 text-orange-500" />;
default:
return <CheckCircle className="w-3.5 h-3.5 text-green-500" />;
}
}
// 状态标签
const STATUS_LABELS: Record<Hand['status'], string> = {
idle: '就绪',
running: '运行中',
needs_approval: '待审批',
error: '错误',
unavailable: '不可用',
setup_needed: '需配置',
};
export function HandList({ selectedHandId, onSelectHand }: HandListProps) {
const { hands, loadHands, isLoading } = useGatewayStore();
useEffect(() => {
loadHands();
}, [loadHands]);
if (isLoading && hands.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-5 h-5 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-xs text-gray-400">...</p>
</div>
);
}
if (hands.length === 0) {
return (
<div className="p-4 text-center">
<Zap className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-xs text-gray-400 mb-1"> Hands</p>
<p className="text-xs text-gray-300"> OpenFang </p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* 头部 */}
<div className="p-3 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 className="text-xs font-semibold text-gray-700"></h3>
<p className="text-xs text-gray-400">{hands.length} </p>
</div>
<button
onClick={() => loadHands()}
disabled={isLoading}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Hands 列表 */}
<div className="flex-1 overflow-y-auto">
{hands.map((hand) => (
<button
key={hand.id}
onClick={() => onSelectHand?.(hand.id)}
className={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-100 transition-colors ${
selectedHandId === hand.id ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''
}`}
>
<div className="flex items-start gap-2">
<span className="text-lg flex-shrink-0">{hand.icon || '🤖'}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-gray-800 text-sm truncate">
{hand.name}
</span>
<HandStatusIcon status={hand.status} />
</div>
<p className="text-xs text-gray-400 truncate mt-0.5">
{hand.description}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">
{STATUS_LABELS[hand.status]}
</span>
{hand.toolCount !== undefined && (
<span className="text-xs text-gray-300">
{hand.toolCount}
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
</div>
);
}
export default HandList;

View File

@@ -0,0 +1,277 @@
/**
* HandTaskPanel - Hand 任务和结果面板
*
* 显示选中 Hand 的任务清单、执行历史和结果。
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Hand } from '../store/gatewayStore';
import {
Zap,
Loader2,
Clock,
CheckCircle,
XCircle,
AlertCircle,
ChevronRight,
Play,
ArrowLeft,
} from 'lucide-react';
interface HandTaskPanelProps {
handId: string;
onBack?: () => void;
}
// 任务状态配置
const RUN_STATUS_CONFIG: Record<string, { label: string; className: string; icon: React.ComponentType<{ className?: string }> }> = {
pending: { label: '等待中', className: 'text-gray-500 bg-gray-100', icon: Clock },
running: { label: '运行中', className: 'text-blue-600 bg-blue-100', icon: Loader2 },
completed: { label: '已完成', className: 'text-green-600 bg-green-100', icon: CheckCircle },
failed: { label: '失败', className: 'text-red-600 bg-red-100', icon: XCircle },
cancelled: { label: '已取消', className: 'text-gray-500 bg-gray-100', icon: XCircle },
needs_approval: { label: '待审批', className: 'text-yellow-600 bg-yellow-100', icon: AlertCircle },
};
// 模拟任务数据(实际应从 API 获取)
interface MockTask {
id: string;
name: string;
status: string;
startedAt: string;
completedAt?: string;
result?: string;
error?: string;
}
export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
const { hands, loadHands, triggerHand } = useGatewayStore();
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
const [tasks, setTasks] = useState<MockTask[]>([]);
const [isActivating, setIsActivating] = useState(false);
useEffect(() => {
loadHands();
}, [loadHands]);
useEffect(() => {
const hand = hands.find(h => h.id === handId || h.name === handId);
setSelectedHand(hand || null);
}, [hands, handId]);
// 模拟加载任务历史
useEffect(() => {
if (selectedHand) {
// TODO: 实际应从 API 获取任务历史
// 目前使用模拟数据
setTasks([
{
id: '1',
name: `${selectedHand.name} - 任务 1`,
status: 'completed',
startedAt: new Date(Date.now() - 3600000).toISOString(),
completedAt: new Date(Date.now() - 3500000).toISOString(),
result: '任务执行成功,生成了 5 个输出文件。',
},
{
id: '2',
name: `${selectedHand.name} - 任务 2`,
status: 'running',
startedAt: new Date(Date.now() - 1800000).toISOString(),
},
{
id: '3',
name: `${selectedHand.name} - 任务 3`,
status: 'needs_approval',
startedAt: new Date(Date.now() - 600000).toISOString(),
},
]);
}
}, [selectedHand]);
const handleActivate = useCallback(async () => {
if (!selectedHand) return;
setIsActivating(true);
try {
await triggerHand(selectedHand.name);
// 刷新 hands 列表
await loadHands();
} catch {
// Error is handled in store
} finally {
setIsActivating(false);
}
}, [selectedHand, triggerHand, loadHands]);
if (!selectedHand) {
return (
<div className="p-8 text-center">
<Zap className="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p className="text-sm text-gray-400"> Hand</p>
</div>
);
}
const runningTasks = tasks.filter(t => t.status === 'running');
const completedTasks = tasks.filter(t => t.status === 'completed' || t.status === 'failed');
const pendingTasks = tasks.filter(t => t.status === 'pending' || t.status === 'needs_approval');
return (
<div className="h-full flex flex-col">
{/* 头部 */}
<div className="p-4 border-b border-gray-200 bg-white flex-shrink-0">
<div className="flex items-center gap-3">
{onBack && (
<button
onClick={onBack}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
<span className="text-2xl">{selectedHand.icon || '🤖'}</span>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-gray-900 truncate">
{selectedHand.name}
</h2>
<p className="text-xs text-gray-500 truncate">{selectedHand.description}</p>
</div>
<button
onClick={handleActivate}
disabled={selectedHand.status !== 'idle' || isActivating}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isActivating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Play className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 运行中的任务 */}
{runningTasks.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<h3 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-3 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
({runningTasks.length})
</h3>
<div className="space-y-2">
{runningTasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</div>
</div>
)}
{/* 待处理任务 */}
{pendingTasks.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
({pendingTasks.length})
</h3>
<div className="space-y-2">
{pendingTasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</div>
</div>
)}
{/* 已完成任务 */}
{completedTasks.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
({completedTasks.length})
</h3>
<div className="space-y-2">
{completedTasks.map(task => (
<TaskCard key={task.id} task={task} expanded />
))}
</div>
</div>
)}
{/* 空状态 */}
{tasks.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1"></p>
<p className="text-xs text-gray-400 dark:text-gray-500">
"执行任务"
</p>
</div>
)}
</div>
</div>
);
}
// 任务卡片组件
function TaskCard({ task, expanded = false }: { task: MockTask; expanded?: boolean }) {
const [isExpanded, setIsExpanded] = useState(expanded);
const config = RUN_STATUS_CONFIG[task.status] || RUN_STATUS_CONFIG.pending;
const StatusIcon = config.icon;
return (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 border border-gray-100 dark:border-gray-700">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2 min-w-0">
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${task.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
{task.name}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs px-2 py-0.5 rounded ${config.className}`}>
{config.label}
</span>
<ChevronRight className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</div>
</div>
{/* 展开详情 */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span></span>
<span>{new Date(task.startedAt).toLocaleString()}</span>
</div>
{task.completedAt && (
<div className="flex justify-between">
<span></span>
<span>{new Date(task.completedAt).toLocaleString()}</span>
</div>
)}
{task.result && (
<div className="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400">
{task.result}
</div>
)}
{task.error && (
<div className="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400">
{task.error}
</div>
)}
</div>
)}
</div>
);
}
export default HandTaskPanel;

View File

@@ -0,0 +1,485 @@
/**
* HandsPanel - OpenFang Hands Management UI
*
* Displays available OpenFang Hands (autonomous capability packages)
* with detailed status, requirements, and activation controls.
*
* Design based on OpenFang Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings } from 'lucide-react';
// === Status Badge Component ===
type HandStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
interface StatusConfig {
label: string;
className: string;
dotClass: string;
}
const STATUS_CONFIG: Record<HandStatus, StatusConfig> = {
idle: {
label: '就绪',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
dotClass: 'bg-green-500',
},
running: {
label: '运行中',
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
dotClass: 'bg-blue-500 animate-pulse',
},
needs_approval: {
label: '待审批',
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
dotClass: 'bg-yellow-500',
},
error: {
label: '错误',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
dotClass: 'bg-red-500',
},
unavailable: {
label: '不可用',
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
dotClass: 'bg-gray-400',
},
setup_needed: {
label: '需配置',
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
dotClass: 'bg-orange-500',
},
};
function HandStatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status as HandStatus] || STATUS_CONFIG.unavailable;
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
{config.label}
</span>
);
}
// === Category Badge Component ===
const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
productivity: { label: '生产力', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
data: { label: '数据', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' },
content: { label: '内容', className: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' },
communication: { label: '通信', className: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' },
};
function CategoryBadge({ category }: { category?: string }) {
if (!category) return null;
const config = CATEGORY_CONFIG[category] || { label: category, className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
return (
<span className={`px-2 py-0.5 rounded text-xs ${config.className}`}>
{config.label}
</span>
);
}
// === Requirement Item Component ===
function RequirementItem({ requirement }: { requirement: HandRequirement }) {
return (
<div className={`flex items-start gap-2 text-sm py-1 ${requirement.met ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
<span className="flex-shrink-0 mt-0.5">
{requirement.met ? (
<CheckCircle className="w-4 h-4" />
) : (
<XCircle className="w-4 h-4" />
)}
</span>
<div className="flex-1 min-w-0">
<span className="break-words">{requirement.description}</span>
{requirement.details && (
<span className="text-gray-400 dark:text-gray-500 text-xs ml-1">({requirement.details})</span>
)}
</div>
</div>
);
}
// === Hand Details Modal Component ===
interface HandDetailsModalProps {
hand: Hand;
isOpen: boolean;
onClose: () => void;
onActivate: () => void;
isActivating: boolean;
}
function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: HandDetailsModalProps) {
if (!isOpen) return null;
const canActivate = hand.status === 'idle' || hand.status === 'setup_needed';
const hasUnmetRequirements = hand.requirements?.some(r => !r.met);
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">
<div className="flex items-center gap-3">
<span className="text-2xl">{hand.icon || '🤖'}</span>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{hand.name}</h2>
<HandStatusBadge status={hand.status} />
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<span className="text-xl">&times;</span>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400">{hand.description}</p>
{/* Agent Config */}
{(hand.provider || hand.model) && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
{hand.provider && (
<div>
<span className="text-gray-500 dark:text-gray-400"></span>
<p className="font-medium text-gray-900 dark:text-white">{hand.provider}</p>
</div>
)}
{hand.model && (
<div>
<span className="text-gray-500 dark:text-gray-400"></span>
<p className="font-medium text-gray-900 dark:text-white">{hand.model}</p>
</div>
)}
</div>
</div>
)}
{/* Requirements */}
{hand.requirements && hand.requirements.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
</h3>
<div className="space-y-1">
{hand.requirements.map((req, idx) => (
<RequirementItem key={idx} requirement={req} />
))}
</div>
</div>
)}
{/* Tools */}
{hand.tools && hand.tools.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
({hand.tools.length})
</h3>
<div className="flex flex-wrap gap-1.5">
{hand.tools.map((tool, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 font-mono"
>
{tool}
</span>
))}
</div>
</div>
)}
{/* Dashboard Metrics */}
{hand.metrics && hand.metrics.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
({hand.metrics.length})
</h3>
<div className="grid grid-cols-3 gap-2">
{hand.metrics.map((metric, idx) => (
<div
key={idx}
className="bg-white dark:bg-gray-800 rounded p-2 text-center border border-gray-200 dark:border-gray-700"
>
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">{metric}</div>
<div className="text-lg font-semibold text-gray-400 dark:text-gray-500">-</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
</button>
<button
onClick={onActivate}
disabled={!canActivate || hasUnmetRequirements || isActivating}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isActivating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : hasUnmetRequirements ? (
<>
<Settings className="w-4 h-4" />
</>
) : (
<>
<Zap className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
// === Hand Card Component ===
interface HandCardProps {
hand: Hand;
onDetails: (hand: Hand) => void;
onActivate: (hand: Hand) => void;
isActivating: boolean;
}
function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps) {
const canActivate = hand.status === 'idle';
const hasUnmetRequirements = hand.requirements_met === false;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-sm hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl flex-shrink-0">{hand.icon || '🤖'}</span>
<h3 className="font-medium text-gray-900 dark:text-white truncate">{hand.name}</h3>
</div>
<HandStatusBadge status={hand.status} />
</div>
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{hand.description}</p>
{/* Requirements Summary (if any unmet) */}
{hasUnmetRequirements && (
<div className="mb-3 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border border-orange-200 dark:border-orange-800">
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400 text-xs font-medium">
<AlertTriangle className="w-3.5 h-3.5" />
<span></span>
</div>
</div>
)}
{/* Meta Info */}
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mb-3">
{hand.toolCount !== undefined && (
<span>{hand.toolCount} </span>
)}
{hand.metricCount !== undefined && (
<span>{hand.metricCount} </span>
)}
{hand.category && (
<CategoryBadge category={hand.category} />
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => onDetails(hand)}
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 flex items-center gap-1"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onActivate(hand)}
disabled={!canActivate || hasUnmetRequirements || isActivating}
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 flex items-center gap-1"
>
{isActivating ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : hand.status === 'running' ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : (
<>
<Zap className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
</div>
);
}
// === Main HandsPanel Component ===
export function HandsPanel() {
const { hands, loadHands, triggerHand, isLoading } = useGatewayStore();
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
loadHands();
}, [loadHands]);
const handleDetails = useCallback(async (hand: Hand) => {
// Load full details before showing modal
const { getHandDetails } = useGatewayStore.getState();
const details = await getHandDetails(hand.name);
setSelectedHand(details || hand);
setShowModal(true);
}, []);
const handleActivate = useCallback(async (hand: Hand) => {
setActivatingHandId(hand.id);
try {
await triggerHand(hand.name);
// Refresh hands after activation
await loadHands();
} catch {
// Error is handled in store
} finally {
setActivatingHandId(null);
}
}, [triggerHand, loadHands]);
const handleCloseModal = useCallback(() => {
setShowModal(false);
setSelectedHand(null);
}, []);
const handleModalActivate = useCallback(async () => {
if (!selectedHand) return;
setShowModal(false);
await handleActivate(selectedHand);
}, [selectedHand, handleActivate]);
if (isLoading && hands.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400"> Hands ...</p>
</div>
);
}
if (hands.length === 0) {
return (
<div className="p-4 text-center">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
<Zap className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3"> Hands</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
OpenFang
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Hands
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<button
onClick={() => loadHands()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
</span>
<span className="text-gray-500 dark:text-gray-400">
<span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
</span>
</div>
{/* Hand Cards Grid */}
<div className="grid gap-3">
{hands.map((hand) => (
<HandCard
key={hand.id}
hand={hand}
onDetails={handleDetails}
onActivate={handleActivate}
isActivating={activatingHandId === hand.id}
/>
))}
</div>
{/* Details Modal */}
{selectedHand && (
<HandDetailsModal
hand={selectedHand}
isOpen={showModal}
onClose={handleCloseModal}
onActivate={handleModalActivate}
isActivating={activatingHandId === selectedHand.id}
/>
)}
</div>
);
}
export default HandsPanel;

View File

@@ -1,19 +1,35 @@
import { useEffect } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { getStoredGatewayUrl } from '../lib/gateway-client';
import { useGatewayStore } from '../store/gatewayStore';
import { useChatStore } from '../store/chatStore';
import { toChatAgent, useChatStore } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, Activity,
MessageSquare, Cpu, FileText, User, Activity, FileCode
} from 'lucide-react';
export function RightPanel() {
const {
connectionState, gatewayVersion, error, clones, usageStats, pluginStatus,
connect, loadClones, loadUsageStats, loadPluginStatus,
connect, loadClones, loadUsageStats, loadPluginStatus, workspaceInfo, quickConfig, updateClone,
} = useGatewayStore();
const { messages, currentModel } = useChatStore();
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent'>('status');
const [isEditingAgent, setIsEditingAgent] = useState(false);
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
const connected = connectionState === 'connected';
const selectedClone = useMemo(
() => clones.find((clone) => clone.id === currentAgent?.id),
[clones, currentAgent?.id]
);
const focusAreas = selectedClone?.scenarios?.length ? selectedClone.scenarios : ['coding', 'research'];
const bootstrapFiles = selectedClone?.bootstrapFiles || [];
const gatewayUrl = quickConfig.gatewayUrl || getStoredGatewayUrl();
useEffect(() => {
if (!selectedClone || isEditingAgent) return;
setAgentDraft(createAgentDraft(selectedClone, currentModel));
}, [selectedClone, currentModel, isEditingAgent]);
// Load data when connected
useEffect(() => {
@@ -28,46 +44,311 @@ export function RightPanel() {
connect().catch(() => {});
};
const handleStartEdit = () => {
if (!selectedClone) return;
setAgentDraft(createAgentDraft(selectedClone, currentModel));
setIsEditingAgent(true);
};
const handleCancelEdit = () => {
if (selectedClone) {
setAgentDraft(createAgentDraft(selectedClone, currentModel));
}
setIsEditingAgent(false);
};
const handleSaveAgent = async () => {
if (!selectedClone || !agentDraft || !agentDraft.name.trim()) return;
const updatedClone = await updateClone(selectedClone.id, {
name: agentDraft.name.trim(),
role: agentDraft.role.trim() || undefined,
nickname: agentDraft.nickname.trim() || undefined,
model: agentDraft.model.trim() || undefined,
scenarios: agentDraft.scenarios.split(',').map((item) => item.trim()).filter(Boolean),
workspaceDir: agentDraft.workspaceDir.trim() || undefined,
userName: agentDraft.userName.trim() || undefined,
userRole: agentDraft.userRole.trim() || undefined,
restrictFiles: agentDraft.restrictFiles,
privacyOptIn: agentDraft.privacyOptIn,
});
if (updatedClone) {
setCurrentAgent(toChatAgent(updatedClone));
setAgentDraft(createAgentDraft(updatedClone, updatedClone.model || currentModel));
setIsEditingAgent(false);
}
};
const userMsgCount = messages.filter(m => m.role === 'user').length;
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length;
const toolCallCount = messages.filter(m => m.role === 'tool').length;
const topMetricValue = usageStats ? usageStats.totalTokens.toLocaleString() : messages.length.toString();
const topMetricLabel = usageStats ? '累计 Token' : '当前消息';
const runtimeSummary = connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接';
const userNameDisplay = selectedClone?.userName || quickConfig.userName || '未设置';
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || '未设置';
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '系统时区';
return (
<aside className="w-72 bg-white border-l border-gray-200 flex flex-col flex-shrink-0">
{/* 顶部 */}
<aside className="w-80 bg-white border-l border-gray-200 flex flex-col flex-shrink-0">
{/* 顶部工具栏 */}
<div className="h-14 border-b border-gray-100 flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-gray-500" />
<span className="font-medium text-gray-700 text-sm"></span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 text-gray-600">
<BarChart3 className="w-4 h-4" />
<span className="font-medium">{topMetricValue}</span>
</div>
<span className="text-xs text-gray-400">{topMetricLabel}</span>
</div>
{connected && (
<div className="flex items-center gap-2 text-gray-500">
<button
onClick={() => { loadUsageStats(); loadPluginStatus(); loadClones(); }}
className="p-1 text-gray-400 hover:text-orange-500 rounded transition-colors"
title="刷新数据"
onClick={() => setActiveTab('status')}
className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${activeTab === 'status' ? 'text-gray-900 bg-gray-100' : 'text-gray-400 hover:text-gray-700'}`}
title="状态"
>
<RefreshCw className="w-3.5 h-3.5" />
<Activity className="w-4 h-4" />
</button>
)}
<button
onClick={() => setActiveTab('files')}
className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${activeTab === 'files' ? 'text-gray-900 bg-gray-100' : 'text-gray-400 hover:text-gray-700'}`}
title="文件"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('agent')}
className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${activeTab === 'agent' ? 'text-gray-900 bg-gray-100' : 'text-gray-400 hover:text-gray-700'}`}
title="Agent"
>
<User className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
{activeTab === 'agent' ? (
<div className="space-y-4">
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-cyan-400 to-blue-500 flex items-center justify-center text-white text-lg font-semibold">
{(selectedClone?.nickname || currentAgent?.name || 'Z').slice(0, 1)}
</div>
<div>
<div className="text-base font-semibold text-gray-900">{selectedClone?.name || currentAgent?.name || 'ZCLAW'}</div>
<div className="text-sm text-gray-500">{selectedClone?.role || 'AI coworker'}</div>
</div>
</div>
{selectedClone ? (
isEditingAgent ? (
<div className="flex gap-2">
<button
onClick={handleCancelEdit}
className="text-xs border border-gray-200 rounded-lg px-3 py-1.5 hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={() => { handleSaveAgent().catch(() => {}); }}
className="text-xs bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600 transition-colors"
>
</button>
</div>
) : (
<button
onClick={handleStartEdit}
className="text-xs border border-gray-200 rounded-lg px-3 py-1.5 hover:bg-gray-50 transition-colors"
>
</button>
)
) : null}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="text-sm font-semibold text-gray-900 mb-3"></div>
{isEditingAgent && agentDraft ? (
<div className="space-y-2">
<AgentInput label="名称" value={agentDraft.name} onChange={(value) => setAgentDraft({ ...agentDraft, name: value })} />
<AgentInput label="角色" value={agentDraft.role} onChange={(value) => setAgentDraft({ ...agentDraft, role: value })} />
<AgentInput label="昵称" value={agentDraft.nickname} onChange={(value) => setAgentDraft({ ...agentDraft, nickname: value })} />
<AgentInput label="模型" value={agentDraft.model} onChange={(value) => setAgentDraft({ ...agentDraft, model: value })} />
</div>
) : (
<div className="space-y-3 text-sm">
<AgentRow label="角色" value={selectedClone?.role || '-'} />
<AgentRow label="昵称" value={selectedClone?.nickname || '-'} />
<AgentRow label="模型" value={selectedClone?.model || currentModel} />
<AgentRow label="Emoji" value={selectedClone?.nickname?.slice(0, 1) || '🦞'} />
</div>
)}
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="text-sm font-semibold text-gray-900 mb-3"></div>
{isEditingAgent && agentDraft ? (
<div className="space-y-2">
<AgentInput label="名字" value={agentDraft.userName} onChange={(value) => setAgentDraft({ ...agentDraft, userName: value })} />
<AgentInput label="角色" value={agentDraft.userRole} onChange={(value) => setAgentDraft({ ...agentDraft, userRole: value })} />
<AgentInput label="场景" value={agentDraft.scenarios} onChange={(value) => setAgentDraft({ ...agentDraft, scenarios: value })} placeholder="coding, research" />
<AgentInput label="工作目录" value={agentDraft.workspaceDir} onChange={(value) => setAgentDraft({ ...agentDraft, workspaceDir: value })} />
<AgentToggle label="文件限制" checked={agentDraft.restrictFiles} onChange={(value) => setAgentDraft({ ...agentDraft, restrictFiles: value })} />
<AgentToggle label="优化计划" checked={agentDraft.privacyOptIn} onChange={(value) => setAgentDraft({ ...agentDraft, privacyOptIn: value })} />
</div>
) : (
<div className="space-y-3 text-sm">
<AgentRow label="名字" value={userNameDisplay} />
<AgentRow label="称呼" value={userAddressing} />
<AgentRow label="时区" value={localTimezone} />
<div className="flex gap-4">
<div className="w-16 text-gray-400"></div>
<div className="flex-1 flex flex-wrap gap-2">
{focusAreas.map((item) => (
<span key={item} className="px-2 py-1 rounded-full bg-gray-100 text-xs text-gray-600">{item}</span>
))}
</div>
</div>
<AgentRow label="工作目录" value={selectedClone?.workspaceDir || workspaceInfo?.path || '~/.openclaw/zclaw-workspace'} />
<AgentRow label="解析目录" value={selectedClone?.workspaceResolvedPath || workspaceInfo?.resolvedPath || '-'} />
<AgentRow label="文件限制" value={selectedClone?.restrictFiles ? '已开启' : '未开启'} />
<AgentRow label="优化计划" value={selectedClone?.privacyOptIn ? '已加入' : '未加入'} />
</div>
)}
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-gray-900">Bootstrap </div>
<span className={`text-xs ${selectedClone?.bootstrapReady ? 'text-green-600' : 'text-gray-400'}`}>
{selectedClone?.bootstrapReady ? '已生成' : '未生成'}
</span>
</div>
<div className="space-y-2 text-sm">
{bootstrapFiles.length > 0 ? bootstrapFiles.map((file) => (
<div key={file.name} className="rounded-lg border border-gray-100 bg-gray-50 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-gray-800">{file.name}</span>
<span className={`text-xs ${file.exists ? 'text-green-600' : 'text-red-500'}`}>
{file.exists ? '存在' : '缺失'}
</span>
</div>
<div className="mt-1 text-xs text-gray-500 break-all">{file.path}</div>
</div>
)) : (
<p className="text-sm text-gray-400"> Agent bootstrap </p>
)}
</div>
</div>
</div>
) : activeTab === 'files' ? (
<div className="space-y-4">
{/* 对话输出文件 */}
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<FileCode className="w-4 h-4" />
</h3>
</div>
{messages.filter(m => m.files && m.files.length > 0).length > 0 ? (
<div className="space-y-2">
{messages.filter(m => m.files && m.files.length > 0).map((msg, msgIdx) => (
<div key={msgIdx} className="space-y-1">
{msg.files!.map((file, fileIdx) => (
<div
key={`${msgIdx}-${fileIdx}`}
className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg text-sm hover:bg-gray-100 cursor-pointer transition-colors"
title={file.path || file.name}
>
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-gray-700 truncate">{file.name}</div>
{file.path && (
<div className="text-xs text-gray-400 truncate">{file.path}</div>
)}
</div>
{file.size && (
<span className="text-xs text-gray-400 flex-shrink-0">
{file.size < 1024 ? `${file.size} B` :
file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` :
`${(file.size / (1024 * 1024)).toFixed(1)} MB`}
</span>
)}
</div>
))}
</div>
))}
</div>
) : (
<div className="text-center py-8">
<FileCode className="w-12 h-12 text-gray-200 mx-auto mb-3" />
<p className="text-sm text-gray-400"></p>
<p className="text-xs text-gray-300 mt-1"> AI </p>
</div>
)}
</div>
{/* 代码块 */}
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900"></h3>
</div>
{messages.filter(m => m.codeBlocks && m.codeBlocks.length > 0).length > 0 ? (
<div className="space-y-2">
{messages.filter(m => m.codeBlocks && m.codeBlocks.length > 0).flatMap((msg, msgIdx) =>
msg.codeBlocks!.map((block, blockIdx) => (
<div
key={`${msgIdx}-${blockIdx}`}
className="px-3 py-2 bg-gray-50 rounded-lg text-sm"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-1.5 py-0.5 bg-gray-200 rounded text-gray-600">
{block.language || 'code'}
</span>
<span className="text-gray-700 truncate">{block.filename || '未命名'}</span>
</div>
<pre className="text-xs text-gray-500 overflow-x-auto max-h-20">
{block.content?.slice(0, 200)}{block.content && block.content.length > 200 ? '...' : ''}
</pre>
</div>
))
).slice(0, 5)}
</div>
) : (
<p className="text-sm text-gray-400 text-center py-4"></p>
)}
</div>
</div>
) : (
<>
{/* Gateway 连接状态 */}
<div className={`rounded-lg border p-3 ${connected ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200'}`}>
<div className="flex items-center gap-2 mb-2">
{connected ? (
<Wifi className="w-4 h-4 text-green-600" />
) : (
<WifiOff className="w-4 h-4 text-gray-400" />
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{connected ? (
<Wifi className="w-4 h-4 text-green-600" />
) : (
<WifiOff className="w-4 h-4 text-gray-400" />
)}
<span className={`text-xs font-semibold ${connected ? 'text-green-700' : 'text-gray-600'}`}>
Gateway {connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
</span>
</div>
{connected && (
<button
onClick={() => { loadUsageStats(); loadPluginStatus(); loadClones(); }}
className="p-1 text-gray-400 hover:text-orange-500 rounded transition-colors"
title="刷新数据"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
<span className={`text-xs font-semibold ${connected ? 'text-green-700' : 'text-gray-600'}`}>
Gateway {connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
</span>
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700 font-mono">127.0.0.1:18789</span>
<span className="text-gray-700 font-mono">{gatewayUrl}</span>
</div>
{gatewayVersion && (
<div className="flex justify-between">
@@ -123,7 +404,7 @@ export function RightPanel() {
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5" />
</h3>
{clones.length > 0 ? (
<div className="space-y-1.5">
@@ -194,24 +475,123 @@ export function RightPanel() {
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5" />
</h3>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">ZCLAW </span>
<span className="text-gray-700">v0.2.0</span>
<span className="text-gray-500"></span>
<span className="text-gray-700">{runtimeSummary}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">Gateway v3</span>
<span className="text-gray-500">Gateway </span>
<span className="text-gray-700">{gatewayVersion || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">Tauri 2.0</span>
<span className="text-gray-500"></span>
<span className="text-gray-700">{clones.length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">{pluginStatus.length}</span>
</div>
</div>
</div>
</>
)}
</div>
</aside>
);
}
function AgentRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-4">
<div className="w-16 text-gray-400">{label}</div>
<div className="flex-1 text-gray-700 break-all">{value}</div>
</div>
);
}
type AgentDraft = {
name: string;
role: string;
nickname: string;
model: string;
scenarios: string;
workspaceDir: string;
userName: string;
userRole: string;
restrictFiles: boolean;
privacyOptIn: boolean;
};
function createAgentDraft(
clone: {
name: string;
role?: string;
nickname?: string;
model?: string;
scenarios?: string[];
workspaceDir?: string;
userName?: string;
userRole?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
},
currentModel: string
): AgentDraft {
return {
name: clone.name || '',
role: clone.role || '',
nickname: clone.nickname || '',
model: clone.model || currentModel,
scenarios: clone.scenarios?.join(', ') || '',
workspaceDir: clone.workspaceDir || '~/.openclaw/zclaw-workspace',
userName: clone.userName || '',
userRole: clone.userRole || '',
restrictFiles: clone.restrictFiles ?? true,
privacyOptIn: clone.privacyOptIn ?? false,
};
}
function AgentInput({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
<label className="block">
<div className="text-xs text-gray-400 mb-1">{label}</div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none"
/>
</label>
);
}
function AgentToggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="flex items-center justify-between text-sm text-gray-700 border border-gray-100 rounded-lg px-3 py-2">
<span>{label}</span>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
</label>
);
}

View File

@@ -0,0 +1,256 @@
/**
* SchedulerPanel - OpenFang Scheduler UI
*
* Displays scheduled jobs, event triggers, and run history.
*
* Design based on OpenFang Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import {
Clock,
Zap,
History,
Plus,
RefreshCw,
Loader2,
Calendar,
} from 'lucide-react';
// === Tab Types ===
type TabType = 'scheduled' | 'triggers' | 'history';
// === Tab Button Component ===
function TabButton({
active,
onClick,
icon: Icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ComponentType<{ className?: string }>;
label: string;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
active
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
);
}
// === Empty State Component ===
function EmptyState({
icon: Icon,
title,
description,
actionLabel,
onAction,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<div className="text-center py-8">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
<Icon className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{title}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mb-4 max-w-sm mx-auto">
{description}
</p>
{actionLabel && onAction && (
<button
onClick={onAction}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
{actionLabel}
</button>
)}
</div>
);
}
// === Main SchedulerPanel Component ===
export function SchedulerPanel() {
const { scheduledTasks, loadScheduledTasks, isLoading } = useGatewayStore();
const [activeTab, setActiveTab] = useState<TabType>('scheduled');
useEffect(() => {
loadScheduledTasks();
}, [loadScheduledTasks]);
const handleCreateJob = useCallback(() => {
// TODO: Implement job creation modal
alert('定时任务创建功能即将推出!');
}, []);
const handleCreateTrigger = useCallback(() => {
// TODO: Implement trigger creation modal
alert('事件触发器创建功能即将推出!');
}, []);
if (isLoading && scheduledTasks.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
...
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
</p>
</div>
<button
onClick={() => loadScheduledTasks()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Tab Navigation */}
<div className="flex items-center justify-between">
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<TabButton
active={activeTab === 'scheduled'}
onClick={() => setActiveTab('scheduled')}
icon={Clock}
label="定时任务"
/>
<TabButton
active={activeTab === 'triggers'}
onClick={() => setActiveTab('triggers')}
icon={Zap}
label="事件触发器"
/>
<TabButton
active={activeTab === 'history'}
onClick={() => setActiveTab('history')}
icon={History}
label="运行历史"
/>
</div>
{activeTab === 'scheduled' && (
<button
onClick={handleCreateJob}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
{/* Tab Content */}
{activeTab === 'scheduled' && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
{scheduledTasks.length === 0 ? (
<EmptyState
icon={Calendar}
title="暂无定时任务"
description="创建一个定时任务来定期运行代理。"
actionLabel="创建定时任务"
onAction={handleCreateJob}
/>
) : (
<div className="space-y-2">
{scheduledTasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{task.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{task.schedule}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded text-xs ${
task.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: task.status === 'paused'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{task.status === 'active' ? '运行中' : task.status === 'paused' ? '已暂停' : task.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'triggers' && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<EmptyState
icon={Zap}
title="暂无事件触发器"
description="事件触发器在系统事件(如收到消息、文件更改或 API webhook发生时触发代理执行。"
actionLabel="创建事件触发器"
onAction={handleCreateTrigger}
/>
</div>
)}
{activeTab === 'history' && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<EmptyState
icon={History}
title="暂无运行历史"
description="当定时任务或事件触发器执行时,运行记录将显示在这里,包括状态和日志。"
/>
</div>
)}
</div>
);
}
export default SchedulerPanel;

View File

@@ -0,0 +1,224 @@
import { useEffect } from 'react';
import { Shield, ShieldCheck, ShieldAlert, ShieldX, RefreshCw } from 'lucide-react';
import { useGatewayStore } from '../store/gatewayStore';
// OpenFang 16-layer security architecture names (Chinese)
const SECURITY_LAYER_NAMES: Record<string, string> = {
// Layer 1: Network
'network.firewall': '网络防火墙',
'network.tls': 'TLS 加密',
'network.rate_limit': '速率限制',
// Layer 2: Authentication
'auth.device': '设备认证',
'auth.jwt': 'JWT 令牌',
'auth.session': '会话管理',
// Layer 3: Authorization
'auth.rbac': '角色权限',
'auth.capabilities': '能力控制',
// Layer 4: Input Validation
'input.sanitization': '输入净化',
'input.schema': '模式验证',
// Layer 5: Execution
'exec.sandbox': '沙箱隔离',
'exec.timeout': '执行超时',
'exec.resource_limit': '资源限制',
// Layer 6: Audit & Logging
'audit.logging': '审计日志',
'audit.tracing': '请求追踪',
};
// Default 16 layers for display when API returns minimal data
const DEFAULT_LAYERS = [
{ name: 'network.firewall', enabled: false },
{ name: 'network.tls', enabled: false },
{ name: 'network.rate_limit', enabled: false },
{ name: 'auth.device', enabled: false },
{ name: 'auth.jwt', enabled: false },
{ name: 'auth.session', enabled: false },
{ name: 'auth.rbac', enabled: false },
{ name: 'auth.capabilities', enabled: false },
{ name: 'input.sanitization', enabled: false },
{ name: 'input.schema', enabled: false },
{ name: 'exec.sandbox', enabled: false },
{ name: 'exec.timeout', enabled: false },
{ name: 'exec.resource_limit', enabled: false },
{ name: 'audit.logging', enabled: false },
{ name: 'audit.tracing', enabled: false },
{ name: 'audit.alerting', enabled: false },
];
function getSecurityIcon(level: 'critical' | 'high' | 'medium' | 'low') {
switch (level) {
case 'critical':
return <ShieldCheck className="w-5 h-5 text-green-600" />;
case 'high':
return <Shield className="w-5 h-5 text-blue-600" />;
case 'medium':
return <ShieldAlert className="w-5 h-5 text-yellow-600" />;
case 'low':
return <ShieldX className="w-5 h-5 text-red-600" />;
}
}
function getSecurityLabel(level: 'critical' | 'high' | 'medium' | 'low') {
switch (level) {
case 'critical':
return { text: '极高', color: 'text-green-600 bg-green-50 border-green-200' };
case 'high':
return { text: '高', color: 'text-blue-600 bg-blue-50 border-blue-200' };
case 'medium':
return { text: '中', color: 'text-yellow-600 bg-yellow-50 border-yellow-200' };
case 'low':
return { text: '低', color: 'text-red-600 bg-red-50 border-red-200' };
}
}
export function SecurityStatus() {
const { connectionState, securityStatus, loadSecurityStatus } = useGatewayStore();
const connected = connectionState === 'connected';
useEffect(() => {
if (connected) {
loadSecurityStatus();
}
}, [connected]);
if (!connected) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-gray-400" />
<span className="text-sm font-semibold text-gray-900"></span>
</div>
<p className="text-xs text-gray-400"></p>
</div>
);
}
// Use default layers if no data, or merge with API data
const displayLayers = securityStatus?.layers?.length
? DEFAULT_LAYERS.map((defaultLayer) => {
const apiLayer = securityStatus.layers.find((l) => l.name === defaultLayer.name);
return apiLayer || defaultLayer;
})
: DEFAULT_LAYERS;
const enabledCount = displayLayers.filter((l) => l.enabled).length;
const totalCount = displayLayers.length;
const securityLevel = securityStatus?.securityLevel ||
(enabledCount >= 14 ? 'critical' : enabledCount >= 10 ? 'high' : enabledCount >= 6 ? 'medium' : 'low');
const levelLabel = getSecurityLabel(securityLevel);
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{getSecurityIcon(securityLevel)}
<span className="text-sm font-semibold text-gray-900"></span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full border ${levelLabel.color}`}>
{levelLabel.text}
</span>
<button
onClick={() => loadSecurityStatus()}
className="p-1 text-gray-400 hover:text-orange-500 rounded transition-colors"
title="刷新安全状态"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Summary */}
<div className="mb-3 text-xs text-gray-500">
{enabledCount} / {totalCount}
</div>
{/* Progress bar */}
<div className="mb-4 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
securityLevel === 'critical'
? 'bg-green-500'
: securityLevel === 'high'
? 'bg-blue-500'
: securityLevel === 'medium'
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{ width: `${(enabledCount / totalCount) * 100}%` }}
/>
</div>
{/* Layers Grid */}
<div className="grid grid-cols-2 gap-1.5">
{displayLayers.map((layer) => {
const label = SECURITY_LAYER_NAMES[layer.name] || layer.name;
return (
<div
key={layer.name}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs ${
layer.enabled
? 'bg-green-50 text-green-700'
: 'bg-gray-50 text-gray-400'
}`}
title={layer.name}
>
<div
className={`w-1.5 h-1.5 rounded-full ${
layer.enabled ? 'bg-green-500' : 'bg-gray-300'
}`}
/>
<span className="truncate">{label}</span>
</div>
);
})}
</div>
{/* Layer Categories */}
<div className="mt-4 pt-3 border-t border-gray-100">
<div className="grid grid-cols-3 gap-2 text-xs">
<CategorySummary
label="网络"
layers={displayLayers.filter((l) => l.name.startsWith('network.'))}
/>
<CategorySummary
label="认证"
layers={displayLayers.filter((l) => l.name.startsWith('auth.'))}
/>
<CategorySummary
label="执行"
layers={displayLayers.filter((l) => l.name.startsWith('exec.'))}
/>
<CategorySummary
label="输入"
layers={displayLayers.filter((l) => l.name.startsWith('input.'))}
/>
<CategorySummary
label="审计"
layers={displayLayers.filter((l) => l.name.startsWith('audit.'))}
/>
</div>
</div>
</div>
);
}
function CategorySummary({ label, layers }: { label: string; layers: { enabled: boolean }[] }) {
if (layers.length === 0) return null;
const enabled = layers.filter((l) => l.enabled).length;
const total = layers.length;
const allEnabled = enabled === total;
return (
<div className="flex flex-col items-center">
<span className={`font-medium ${allEnabled ? 'text-green-600' : 'text-gray-500'}`}>
{enabled}/{total}
</span>
<span className="text-gray-400">{label}</span>
</div>
);
}

View File

@@ -1,39 +1,42 @@
import { RefreshCw, Cat } from 'lucide-react';
export function About() {
return (
<div>
<div className="max-w-3xl">
<div className="flex items-center gap-4 mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 to-red-500 rounded-2xl flex items-center justify-center text-3xl shadow-lg">
🦞
<div className="w-16 h-16 bg-black rounded-2xl flex items-center justify-center text-white shadow-md">
<Cat className="w-10 h-10" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">ZCLAW</h1>
<p className="text-sm text-orange-500"> 0.2.0</p>
<h1 className="text-xl font-bold text-gray-900">ZCLAW</h1>
<div className="text-sm text-gray-500"> 0.2.0</div>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-5 mb-4">
<div className="flex justify-between items-center">
<div className="space-y-4">
<div className="bg-white rounded-xl border border-gray-200 p-4 flex justify-between items-center shadow-sm">
<span className="text-sm text-gray-700"></span>
<button className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-4 py-2 rounded-lg flex items-center gap-1 transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 flex justify-between items-center shadow-sm">
<div>
<h2 className="text-sm font-bold text-gray-900"></h2>
<div className="text-sm text-gray-700 mb-1"></div>
<div className="text-xs text-gray-500"></div>
</div>
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600 flex items-center gap-1">
🔄
<button className="text-xs px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
</button>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-5 mb-8">
<div className="flex justify-between items-center">
<div>
<h2 className="text-sm font-bold text-gray-900"></h2>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<button className="border border-gray-300 rounded-lg px-4 py-1.5 text-sm hover:bg-gray-100"></button>
</div>
<div className="mt-12 text-center text-xs text-gray-400">
2026 ZCLAW | Powered by OpenClaw
</div>
<div className="text-center text-xs text-gray-400 space-y-1">
<p>© 2026 ZCLAW | Powered by OpenClaw</p>
<p> OpenClaw </p>
<div className="flex justify-center gap-4 mt-3">
<a href="#" className="text-orange-500 hover:text-orange-600"></a>

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
export function Credits() {
const [filter, setFilter] = useState<'all' | 'consume' | 'earn'>('all');
const logs = [
{ id: 1, action: 'AutoClaw网页搜索', date: '2026年03月11日 12:02:02', amount: -6 },
{ id: 2, action: 'AutoClaw网页搜索', date: '2026年03月11日 12:01:58', amount: -6 },
{ id: 3, action: 'AutoClaw网页搜索', date: '2026年03月11日 12:01:46', amount: -6 },
{ id: 4, action: 'AutoClaw网页搜索', date: '2026年03月11日 12:01:43', amount: -6 },
];
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>
<div className="flex gap-2">
<button className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors">
</button>
<button className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg transition-colors">
</button>
</div>
</div>
<div className="text-center mb-8">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-3xl font-bold text-gray-900">2268</div>
</div>
<div className="p-1 mb-6 flex rounded-lg bg-gray-50 border border-gray-100 shadow-sm">
<button
onClick={() => setFilter('all')}
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'all' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setFilter('consume')}
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'consume' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setFilter('earn')}
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'earn' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
</div>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm">
{logs.map((log) => (
<div key={log.id} className="flex justify-between items-center p-4">
<div>
<div className="text-sm text-gray-700">{log.action}</div>
<div className="text-xs text-gray-500 mt-1">{log.date}</div>
</div>
<div className={`font-medium ${log.amount < 0 ? 'text-gray-500' : 'text-green-500'}`}>
{log.amount > 0 ? '+' : ''}{log.amount}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,24 @@
import { useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
import { getStoredGatewayToken, setStoredGatewayToken, setStoredGatewayUrl } from '../../lib/gateway-client';
type BackendType = 'openclaw' | 'openfang';
function getStoredBackendType(): BackendType {
try {
const stored = localStorage.getItem('zclaw-backend');
return (stored === 'openfang' || stored === 'openclaw') ? stored : 'openclaw';
} catch {
return 'openclaw';
}
}
function setStoredBackendType(type: BackendType): void {
try {
localStorage.setItem('zclaw-backend', type);
} catch { /* ignore */ }
}
export function General() {
const { connectionState, gatewayVersion, error, connect, disconnect } = useGatewayStore();
@@ -8,13 +26,32 @@ export function General() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [autoStart, setAutoStart] = useState(false);
const [showToolCalls, setShowToolCalls] = useState(false);
const [backendType, setBackendType] = useState<BackendType>(getStoredBackendType());
const [gatewayToken, setGatewayToken] = useState(getStoredGatewayToken());
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
const handleConnect = () => { connect().catch(() => {}); };
const handleConnect = () => {
connect(undefined, gatewayToken || undefined).catch(() => {});
};
const handleDisconnect = () => { disconnect(); };
const handleBackendChange = (type: BackendType) => {
setBackendType(type);
setStoredBackendType(type);
// Update Gateway URL when switching backend type
const newUrl = type === 'openfang'
? 'ws://127.0.0.1:50051/ws'
: 'ws://127.0.0.1:18789';
setStoredGatewayUrl(newUrl);
// Reconnect with new URL
disconnect();
setTimeout(() => {
connect(undefined, gatewayToken || undefined).catch(() => {});
}, 100);
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8"></h1>
@@ -32,7 +69,20 @@ export function General() {
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-gray-500 font-mono">ws://127.0.0.1:18789</span>
<span className="text-sm text-gray-500 font-mono">{backendType === 'openfang' ? 'ws://127.0.0.1:50051' : 'ws://127.0.0.1:18789'}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700">Token</span>
<input
type="password"
value={gatewayToken}
onChange={(e) => {
setGatewayToken(e.target.value);
setStoredGatewayToken(e.target.value);
}}
placeholder="可选Gateway auth token"
className="w-72 px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none text-gray-500 font-mono"
/>
</div>
{gatewayVersion && (
<div className="flex justify-between items-center">
@@ -102,6 +152,41 @@ export function General() {
<Toggle checked={showToolCalls} onChange={setShowToolCalls} />
</div>
</div>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3 mt-6"></h2>
<div className="bg-gray-50 rounded-xl p-5 space-y-4">
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-gray-900">Gateway </div>
<div className="text-xs text-gray-500 mt-0.5"> OpenClaw (TypeScript) OpenFang (Rust) </div>
</div>
<select
value={backendType}
onChange={(e) => handleBackendChange(e.target.value as BackendType)}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-orange-500 text-gray-700"
>
<option value="openclaw">OpenClaw (TypeScript)</option>
<option value="openfang">OpenFang (Rust)</option>
</select>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-gray-500 font-mono">{backendType === 'openfang' ? '50051' : '18789'}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-gray-500">{backendType === 'openfang' ? 'WebSocket + REST API' : 'WebSocket RPC'}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-gray-500">{backendType === 'openfang' ? 'TOML' : 'JSON/YAML'}</span>
</div>
{backendType === 'openfang' && (
<div className="text-xs text-blue-700 bg-blue-50 rounded-lg p-3">
OpenFang 7 (Hands)16 OpenFang
</div>
)}
</div>
</div>
);
}

View File

@@ -1,32 +1,113 @@
import { Plus, RefreshCw } from 'lucide-react';
import { useEffect } from 'react';
import { Radio, RefreshCw, MessageCircle, Settings2 } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
const CHANNEL_ICONS: Record<string, string> = {
feishu: '飞',
qqbot: 'QQ',
wechat: '微',
};
export function IMChannels() {
const { channels, connectionState, loadChannels, loadPluginStatus } = useGatewayStore();
const connected = connectionState === 'connected';
const loading = connectionState === 'connecting' || connectionState === 'reconnecting' || connectionState === 'handshaking';
useEffect(() => {
if (connected) {
loadPluginStatus().then(() => loadChannels());
}
}, [connected]);
const handleRefresh = () => {
loadPluginStatus().then(() => loadChannels());
};
const knownChannels = [
{ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)' },
{ id: 'qqbot', type: 'qqbot', label: 'QQ 机器人' },
{ id: 'wechat', type: 'wechat', label: '微信' },
];
const availableChannels = knownChannels.filter(
(channel) => !channels.some((item) => item.type === channel.type)
);
return (
<div>
<div className="flex justify-between items-center mb-2">
<h1 className="text-2xl font-bold text-gray-900">IM </h1>
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold text-gray-900">IM </h1>
<div className="flex gap-2">
<button className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />
<span className="text-xs text-gray-400 flex items-center">
{connected ? `${channels.length} 个已识别频道` : loading ? '连接中...' : '未连接 Gateway'}
</span>
<button
onClick={handleRefresh}
disabled={!connected}
className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg flex items-center gap-1 transition-colors disabled:opacity-50"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-8 text-center mt-6">
<button className="bg-orange-500 text-white text-sm rounded-lg px-4 py-2 hover:bg-orange-600 mb-3">
</button>
<p className="text-sm text-gray-500"> IM </p>
<p className="text-xs text-gray-400 mt-1"> IM </p>
</div>
{!connected ? (
<div className="bg-white rounded-xl border border-gray-200 h-64 flex flex-col items-center justify-center mb-6 shadow-sm text-gray-400">
<Radio className="w-8 h-8 mb-3 opacity-40" />
<span className="text-sm"> Gateway IM </span>
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 mb-6 shadow-sm divide-y divide-gray-100">
{channels.length > 0 ? channels.map((channel) => (
<div key={channel.id} className="p-4 flex items-center gap-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-semibold ${
channel.status === 'active'
? 'bg-gradient-to-br from-blue-500 to-indigo-500'
: channel.status === 'error'
? 'bg-gradient-to-br from-red-500 to-rose-500'
: 'bg-gray-300'
}`}>
{CHANNEL_ICONS[channel.type] || <MessageCircle className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900">{channel.label}</div>
<div className={`text-xs mt-1 ${
channel.status === 'active'
? 'text-green-600'
: channel.status === 'error'
? 'text-red-500'
: 'text-gray-400'
}`}>
{channel.status === 'active' ? '已连接' : channel.status === 'error' ? channel.error || '错误' : '未配置'}
{channel.accounts !== undefined && channel.accounts > 0 ? ` · ${channel.accounts} 个账号` : ''}
</div>
</div>
<div className="text-xs text-gray-400">{channel.type}</div>
</div>
)) : (
<div className="h-40 flex items-center justify-center text-sm text-gray-400">
</div>
)}
</div>
)}
<div className="mt-8">
<h2 className="text-sm font-medium text-gray-700 mb-3"></h2>
<div className="flex gap-2">
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ </button>
<div>
<div className="text-xs text-gray-500 mb-3"></div>
<div className="flex flex-wrap gap-3">
{availableChannels.map((channel) => (
<span
key={channel.id}
className="text-xs text-gray-500 bg-gray-100 px-4 py-2 rounded-lg"
>
{channel.label}
</span>
))}
<div className="text-xs text-gray-400 flex items-center gap-1">
<Settings2 className="w-3 h-3" />
channelaccountbinding Gateway
</div>
</div>
</div>
</div>

View File

@@ -1,63 +1,61 @@
import { useState } from 'react';
import { Plus, RefreshCw } from 'lucide-react';
interface MCPService {
id: string;
name: string;
enabled: boolean;
}
import { FileText, Globe } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
export function MCPServices() {
const [services, setServices] = useState<MCPService[]>([
{ id: 'filesystem', name: 'File System', enabled: true },
{ id: 'webfetch', name: 'Web Fetch', enabled: true },
]);
const { quickConfig, saveQuickConfig } = useGatewayStore();
const toggleService = (id: string) => {
setServices(prev => prev.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
const services = quickConfig.mcpServices || [];
const toggleService = async (id: string) => {
const nextServices = services.map((service) =>
service.id === id ? { ...service, enabled: !service.enabled } : service
);
await saveQuickConfig({ mcpServices: nextServices });
};
return (
<div>
<div className="flex justify-between items-center mb-2">
<h1 className="text-2xl font-bold text-gray-900">MCP </h1>
<div className="flex gap-2">
<button className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />
</button>
</div>
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold text-gray-900">MCP </h1>
<span className="text-xs text-gray-400">{services.length} </span>
</div>
<div className="text-xs text-gray-500 mb-6">
MCP Agent
</div>
<p className="text-sm text-gray-500 mb-6">MCP Agent </p>
<div className="bg-gray-50 rounded-xl divide-y divide-gray-200">
{services.map((svc) => (
<div key={svc.id} className="flex items-center justify-between px-5 py-4">
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm mb-6">
{services.length > 0 ? services.map((svc) => (
<div key={svc.id} className="flex justify-between items-center p-4">
<div className="flex items-center gap-3">
<span className="text-gray-400"></span>
<span className="text-sm text-gray-900">{svc.name}</span>
{svc.id === 'filesystem'
? <FileText className="w-4 h-4 text-gray-500" />
: <Globe className="w-4 h-4 text-gray-500" />}
<div>
<div className="text-sm text-gray-900">{svc.name}</div>
<div className="text-xs text-gray-400 mt-1">{svc.id}</div>
</div>
</div>
<div className="flex items-center gap-3">
<button onClick={() => toggleService(svc.id)} className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100">
<div className="flex gap-2 items-center">
<span className={`text-xs px-2 py-1 rounded-full ${svc.enabled ? 'bg-green-50 text-green-600' : 'bg-gray-100 text-gray-500'}`}>
{svc.enabled ? '已启用' : '已停用'}
</span>
<button
onClick={() => { toggleService(svc.id).catch(() => {}); }}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
{svc.enabled ? '停用' : '启用'}
</button>
<button className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100"></button>
<button className="text-xs text-gray-500 border border-gray-300 rounded px-2 py-1 hover:bg-gray-100"></button>
</div>
</div>
))}
)) : (
<div className="p-8 text-center text-sm text-gray-400">
MCP
</div>
)}
</div>
<div className="mt-6">
<h2 className="text-sm font-medium text-gray-700 mb-3"></h2>
<p className="text-xs text-gray-400 mb-3"> MCP </p>
<div className="flex gap-2">
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ Brave Search</button>
<button className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600">+ SQLite</button>
</div>
<div className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
MCP
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { getStoredGatewayToken, getStoredGatewayUrl } from '../../lib/gateway-client';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
@@ -9,93 +10,135 @@ interface ModelEntry {
}
const AVAILABLE_MODELS: ModelEntry[] = [
{ id: 'glm-5', name: 'GLM-5', provider: '智谱 AI' },
{ id: 'qwen3.5-plus', name: 'Qwen3.5+', provider: '通义千问' },
{ id: 'kimi-k2.5', name: 'Kimi-K2.5', provider: '月之暗面' },
{ id: 'glm-5', name: 'glm-5', provider: '智谱 AI' },
{ id: 'qwen3.5-plus', name: 'qwen3.5-plus', provider: '通义千问' },
{ id: 'kimi-k2.5', name: 'kimi-k2.5', provider: '月之暗面' },
{ id: 'minimax-m2.5', name: 'MiniMax-M2.5', provider: 'MiniMax' },
];
export function ModelsAPI() {
const { connectionState, connect, disconnect } = useGatewayStore();
const { connectionState, connect, disconnect, quickConfig, saveQuickConfig } = useGatewayStore();
const { currentModel, setCurrentModel } = useChatStore();
const [gatewayUrl, setGatewayUrl] = useState('ws://127.0.0.1:18789');
const [gatewayUrl, setGatewayUrl] = useState(getStoredGatewayUrl());
const [gatewayToken, setGatewayToken] = useState(quickConfig.gatewayToken || getStoredGatewayToken());
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
useEffect(() => {
setGatewayUrl(quickConfig.gatewayUrl || getStoredGatewayUrl());
setGatewayToken(quickConfig.gatewayToken || getStoredGatewayToken());
}, [quickConfig.gatewayToken, quickConfig.gatewayUrl]);
const handleReconnect = () => {
disconnect();
setTimeout(() => connect().catch(() => {}), 500);
setTimeout(() => connect(
gatewayUrl || quickConfig.gatewayUrl || 'ws://127.0.0.1:18789',
gatewayToken || quickConfig.gatewayToken || getStoredGatewayToken()
).catch(() => {}), 500);
};
const handleSaveGatewaySettings = () => {
saveQuickConfig({
gatewayUrl,
gatewayToken,
}).catch(() => {});
};
return (
<div>
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900"> API</h1>
<h1 className="text-xl font-bold text-gray-900"> API</h1>
<button
onClick={handleReconnect}
disabled={connecting}
className="text-sm text-gray-500 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 disabled:opacity-50"
className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
{connecting ? '连接中...' : '重新连接'}
</button>
</div>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3"> Provider</h2>
<div className="bg-gray-50 rounded-xl divide-y divide-gray-200 mb-6">
{AVAILABLE_MODELS.map((model) => {
const isActive = model.id === currentModel;
return (
<div key={model.id} className="flex items-center justify-between px-5 py-3.5">
<div>
<span className="text-sm font-medium text-gray-900">{model.name}</span>
<span className="text-xs text-gray-400 ml-2">{model.provider}</span>
</div>
<div className="flex items-center gap-3">
{isActive ? (
<span className="text-xs text-green-600 bg-green-50 px-2.5 py-1 rounded-md font-medium">使</span>
) : (
<button
onClick={() => setCurrentModel(model.id)}
className="text-xs text-orange-500 hover:text-orange-600 hover:bg-orange-50 px-2.5 py-1 rounded-md transition-colors"
>
</button>
)}
</div>
</div>
);
})}
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wider"></h3>
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500"></span>
<span className="text-sm font-medium text-orange-600">{currentModel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Gateway </span>
<span className={`text-sm ${connected ? 'text-green-600' : connecting ? 'text-yellow-600' : 'text-gray-400'}`}>
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
</span>
</div>
</div>
</div>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">Gateway </h2>
<div className="bg-gray-50 rounded-xl p-5 space-y-4">
<div className="flex items-center gap-3">
<span className={`text-xs px-2.5 py-1 rounded-md font-medium ${connected ? 'bg-green-50 text-green-600' : 'bg-gray-200 text-gray-500'}`}>
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider"></h3>
<span className="text-xs text-gray-400"></span>
</div>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm">
{AVAILABLE_MODELS.map((model) => {
const isActive = model.id === currentModel;
return (
<div key={model.id} className={`flex justify-between items-center p-4 ${isActive ? 'bg-orange-50/50' : ''}`}>
<div>
<div className="text-sm text-gray-900">{model.name}</div>
<div className="text-xs text-gray-400 mt-1">{model.provider}</div>
</div>
<div className="flex gap-2 text-xs items-center">
{isActive ? (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs"></span>
) : (
<button onClick={() => setCurrentModel(model.id)} className="text-orange-600 hover:underline"></button>
)}
</div>
</div>
);
})}
</div>
<div className="mt-3 text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
Gateway Provider Key
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">Gateway URL</span>
<span className={`px-2 py-0.5 rounded text-xs border ${connected ? 'bg-green-50 text-green-600 border-green-100' : 'bg-red-50 text-red-600 border-red-100'}`}>
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
</span>
{!connected && !connecting && (
<button
onClick={() => connect().catch(() => {})}
className="text-sm bg-orange-500 text-white rounded-lg px-3 py-1.5 hover:bg-orange-600"
>
</button>
)}
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Gateway WebSocket URL</label>
<input
type="text"
value={gatewayUrl}
onChange={(e) => setGatewayUrl(e.target.value)}
className="w-full bg-white border border-gray-200 rounded-lg text-sm text-gray-700 font-mono px-3 py-2 focus:outline-none focus:ring-2 focus:ring-orange-200 focus:border-orange-300"
/>
<div className="flex gap-2">
<button onClick={handleReconnect} className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors">
</button>
<button onClick={handleSaveGatewaySettings} className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg transition-colors">
</button>
</div>
<p className="text-xs text-gray-400">
默认地址: ws://127.0.0.1:18789。修改后需重新连接。
</p>
</div>
<div className="space-y-3 bg-gray-50 border border-gray-200 rounded-xl p-3 text-xs text-gray-600 font-mono shadow-sm">
<input
type="text"
value={gatewayUrl}
onChange={(e) => setGatewayUrl(e.target.value)}
onBlur={() => { saveQuickConfig({ gatewayUrl }).catch(() => {}); }}
className="w-full bg-transparent border-none outline-none"
/>
<input
type="password"
value={gatewayToken}
onChange={(e) => setGatewayToken(e.target.value)}
onBlur={() => { saveQuickConfig({ gatewayToken }).catch(() => {}); }}
placeholder="Gateway auth token"
className="w-full bg-transparent border-none outline-none"
/>
</div>
</div>
);
}

View File

@@ -1,55 +1,81 @@
import { useState } from 'react';
import { useEffect } from 'react';
import { ExternalLink } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
export function Privacy() {
const [optimization, setOptimization] = useState(false);
const { quickConfig, workspaceInfo, loadWorkspaceInfo, saveQuickConfig } = useGatewayStore();
useEffect(() => {
loadWorkspaceInfo().catch(() => {});
}, []);
const optIn = quickConfig.privacyOptIn ?? false;
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-sm text-gray-500 mb-6"> ZCLAW </p>
<div className="max-w-3xl">
<h1 className="text-xl font-bold mb-2 text-gray-900"></h1>
<div className="text-xs text-gray-500 mb-6"> ZCLAW </div>
<div className="bg-gray-50 rounded-xl p-5 mb-6">
<h2 className="text-sm font-bold text-gray-900 mb-1"></h2>
<p className="text-xs text-gray-500 mb-3"> Agent </p>
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<span className="text-sm text-gray-700 font-mono">~/.openclaw/zclaw-workspace</span>
<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>
<div className="text-xs text-gray-500 mb-3"> Agent </div>
<div className="p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-600 font-mono">
{workspaceInfo?.resolvedPath || workspaceInfo?.path || quickConfig.workspaceDir || '~/.openclaw/zclaw-workspace'}
</div>
</div>
<div className="bg-gray-50 rounded-xl p-5 mb-6">
<div className="flex justify-between items-start">
<div className="flex-1 mr-4">
<h2 className="text-sm font-bold text-gray-900"></h2>
<p className="text-xs text-gray-500 mt-1 leading-relaxed">
使"设置"退
</p>
</div>
<button
onClick={() => setOptimization(!optimization)}
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 ${optimization ? 'bg-orange-500' : 'bg-gray-300'}`}
>
<span className={`block w-5 h-5 bg-white rounded-full shadow absolute top-0.5 transition-all ${optimization ? 'left-[22px]' : 'left-0.5'}`} />
</button>
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6 shadow-sm">
<div className="flex justify-between items-start mb-4">
<h3 className="font-medium text-gray-900"></h3>
<Toggle checked={optIn} onChange={(value) => { saveQuickConfig({ privacyOptIn: value }).catch(() => {}); }} />
</div>
<p className="text-xs text-gray-500 leading-relaxed">
使"设置"退
</p>
</div>
<div className="bg-gray-50 rounded-xl p-5">
<h2 className="text-sm font-bold text-gray-900 mb-3"></h2>
<div className="space-y-2 text-xs text-gray-500">
<div className="flex gap-8">
<span className="text-gray-400 w-24"></span>
<span>ZCLAW OpenClaw </span>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<h3 className="font-medium mb-4 text-gray-900"></h3>
<div className="space-y-3 text-xs">
<div className="flex">
<span className="text-gray-500 w-28 flex-shrink-0">ICP /</span>
<span className="text-gray-700"> ICP 20011824 -21</span>
</div>
<div className="flex gap-8">
<span className="text-gray-400 w-24"></span>
<span>MIT License</span>
<div className="flex">
<span className="text-gray-500 w-28 flex-shrink-0"></span>
<div className="space-y-1 text-gray-700">
<div> ChatGLM 110108105858001230019 </div>
<div> ChatGLM 110108105858004240011 </div>
</div>
</div>
<div className="flex gap-8">
<span className="text-gray-400 w-24"></span>
<span></span>
<div className="flex">
<span className="text-gray-500 w-28 flex-shrink-0"></span>
<span className="text-gray-700">Beijing-AutoGLM-2025060650053</span>
</div>
</div>
<div className="flex gap-4 mt-6 pt-4 border-t border-gray-100">
<a href="#" className="text-orange-600 text-xs hover:underline flex items-center gap-1">
<ExternalLink className="w-3 h-3" />
</a>
<a href="#" className="text-orange-600 text-xs hover:underline flex items-center gap-1">
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 mt-1 ${checked ? 'bg-orange-500' : 'bg-gray-200'}`}
>
<span className={`block w-5 h-5 bg-white rounded-full shadow-sm absolute top-0.5 transition-all ${checked ? 'left-[22px]' : 'left-0.5'}`} />
</button>
);
}

View File

@@ -2,15 +2,18 @@ import { useState } from 'react';
import {
Settings as SettingsIcon,
BarChart3,
Bot,
Puzzle,
Blocks,
MessageSquare,
FolderOpen,
Shield,
MessageCircle,
Info,
ArrowLeft,
Coins,
Cpu,
Zap,
HelpCircle,
ClipboardList,
Clock,
} from 'lucide-react';
import { General } from './General';
import { UsageStats } from './UsageStats';
@@ -21,6 +24,10 @@ import { IMChannels } from './IMChannels';
import { Workspace } from './Workspace';
import { Privacy } from './Privacy';
import { About } from './About';
import { Credits } from './Credits';
import { AuditLogsPanel } from '../AuditLogsPanel';
import { SecurityStatus } from '../SecurityStatus';
import { TaskList } from '../TaskList';
interface SettingsLayoutProps {
onBack: () => void;
@@ -29,25 +36,33 @@ interface SettingsLayoutProps {
type SettingsPage =
| 'general'
| 'usage'
| 'credits'
| 'models'
| 'mcp'
| 'skills'
| 'im'
| 'workspace'
| 'privacy'
| 'security'
| 'audit'
| 'tasks'
| 'feedback'
| 'about';
const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] = [
{ id: 'general', label: '通用', icon: <SettingsIcon className="w-4 h-4" /> },
{ id: 'usage', label: '用量统计', icon: <BarChart3 className="w-4 h-4" /> },
{ id: 'models', label: '模型与 API', icon: <Bot className="w-4 h-4" /> },
{ id: 'credits', label: '积分详情', icon: <Coins className="w-4 h-4" /> },
{ id: 'models', label: '模型与 API', icon: <Cpu className="w-4 h-4" /> },
{ id: 'mcp', label: 'MCP 服务', icon: <Puzzle className="w-4 h-4" /> },
{ id: 'skills', label: '技能', icon: <Blocks className="w-4 h-4" /> },
{ id: 'skills', label: '技能', icon: <Zap className="w-4 h-4" /> },
{ id: 'im', label: 'IM 频道', icon: <MessageSquare className="w-4 h-4" /> },
{ id: 'workspace', label: '工作区', icon: <FolderOpen className="w-4 h-4" /> },
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
{ id: 'feedback', label: '提交反馈', icon: <MessageCircle className="w-4 h-4" /> },
{ id: 'security', label: '安全状态', icon: <Shield className="w-4 h-4" /> },
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" /> },
{ id: 'tasks', label: '定时任务', icon: <Clock 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" /> },
];
@@ -58,12 +73,28 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
switch (activePage) {
case 'general': return <General />;
case 'usage': return <UsageStats />;
case 'credits': return <Credits />;
case 'models': return <ModelsAPI />;
case 'mcp': return <MCPServices />;
case 'skills': return <Skills />;
case 'im': return <IMChannels />;
case 'workspace': return <Workspace />;
case 'privacy': return <Privacy />;
case 'security': return (
<div className="max-w-3xl">
<h1 className="text-xl font-bold text-gray-900 mb-6"></h1>
<SecurityStatus />
</div>
);
case 'audit': return <AuditLogsPanel />;
case 'tasks': return (
<div className="max-w-3xl">
<h1 className="text-xl font-bold text-gray-900 mb-6"></h1>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<TaskList />
</div>
</div>
);
case 'feedback': return <Feedback />;
case 'about': return <About />;
default: return <General />;
@@ -71,40 +102,42 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
};
return (
<div className="h-screen flex bg-white">
<div className="h-screen flex bg-f9fafb overflow-hidden text-gray-800 text-sm">
{/* Left navigation */}
<aside className="w-56 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-3 text-sm text-gray-500 hover:text-gray-700 border-b border-gray-200"
>
<ArrowLeft className="w-4 h-4" />
</button>
<aside className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
{/* 返回按钮 */}
<div className="p-4 border-b border-gray-200">
<button
onClick={onBack}
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span></span>
</button>
</div>
<nav className="flex-1 py-2">
{/* 导航菜单 */}
<nav className="flex-1 overflow-y-auto custom-scrollbar py-2 px-3 space-y-1">
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => setActivePage(item.id)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${
activePage === item.id
? 'bg-orange-50 text-orange-600 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
? 'bg-gray-200 text-gray-900 font-medium'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-700'
}`}
>
{item.icon}
{item.label}
<span>{item.label}</span>
</button>
))}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-8 py-8">
{renderPage()}
</div>
<main className="flex-1 overflow-y-auto custom-scrollbar bg-white p-8">
{renderPage()}
</main>
</div>
);
@@ -113,22 +146,36 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
// Simple feedback page (inline)
function Feedback() {
const [text, setText] = useState('');
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text.trim());
setCopied(true);
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-sm text-gray-500 mb-6">便</p>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="请尽量详细描述复现步骤、期望结果和实际结果"
className="w-full h-40 border border-gray-300 rounded-lg p-3 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
<button
disabled={!text.trim()}
className="mt-4 px-6 py-2 bg-orange-500 text-white text-sm rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<div className="max-w-3xl">
<h1 className="text-xl font-bold text-gray-900 mb-6"></h1>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<p className="text-sm text-gray-500 mb-4">线</p>
<textarea
value={text}
onChange={(e) => {
setText(e.target.value);
if (copied) {
setCopied(false);
}
}}
placeholder="请尽量详细描述复现步骤、期望结果和实际结果"
className="w-full h-40 border border-gray-300 rounded-lg p-3 text-sm resize-none focus:outline-none focus:border-orange-400"
/>
<button
onClick={() => { handleCopy().catch(() => {}); }}
disabled={!text.trim()}
className="mt-4 px-6 py-2 bg-orange-500 text-white text-sm rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{copied ? '已复制' : '复制反馈内容'}
</button>
</div>
</div>
);
}

View File

@@ -1,50 +1,94 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
export function Skills() {
const [extraDir, setExtraDir] = useState('~/.opencode/skills');
const [activeTab, setActiveTab] = useState<'all' | 'available' | 'installed'>('all');
const { connectionState, quickConfig, skillsCatalog, loadSkillsCatalog, saveQuickConfig } = useGatewayStore();
const connected = connectionState === 'connected';
const [extraDir, setExtraDir] = useState('');
useEffect(() => {
if (connected) {
loadSkillsCatalog().catch(() => {});
}
}, [connected]);
const extraDirs = quickConfig.skillsExtraDirs || [];
const handleAddDir = async () => {
const nextDir = extraDir.trim();
if (!nextDir) return;
const nextDirs = Array.from(new Set([...extraDirs, nextDir]));
await saveQuickConfig({ skillsExtraDirs: nextDirs });
setExtraDir('');
await loadSkillsCatalog();
};
return (
<div>
<div className="flex justify-between items-center mb-2">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<button className="text-sm text-gray-400">...</button>
<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={() => { loadSkillsCatalog().catch(() => {}); }}
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>
<p className="text-sm text-gray-500 mb-6">
Agent SKILL.md
</p>
<div className="bg-gray-50 rounded-xl p-5 mb-6">
<h2 className="text-sm font-bold text-gray-900 mb-1"></h2>
<p className="text-xs text-gray-500 mb-3"> SKILL.md Gateway skills.load.extraDirs </p>
{!connected && (
<div className="bg-gray-50/50 border border-gray-200 rounded-xl p-4 mb-6 text-center text-sm text-gray-500 shadow-sm">
Gateway Gateway
</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)}
className="flex-1 bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500"
placeholder="输入额外技能目录"
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none"
/>
<button className="bg-orange-500 text-white text-sm rounded-lg px-4 py-2 hover:bg-orange-600"></button>
</div>
</div>
<div className="flex gap-2 mb-4">
{(['all', 'available', 'installed'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs px-3 py-1 rounded-full ${
activeTab === tab ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
onClick={() => { handleAddDir().catch(() => {}); }}
className="text-xs text-gray-500 px-4 py-2 border border-gray-200 rounded-lg hover:text-gray-700 transition-colors"
>
{tab === 'all' ? '全部 (0)' : tab === 'available' ? '可用 (0)' : '已安装 (0)'}
</button>
))}
</div>
{extraDirs.length > 0 && (
<div className="mt-4 space-y-2">
{extraDirs.map((dir) => (
<div key={dir} className="text-xs text-gray-500 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
{dir}
</div>
))}
</div>
)}
</div>
<div className="bg-gray-50 rounded-xl p-8 text-center">
<p className="text-sm text-gray-400"></p>
<p className="text-xs text-gray-300 mt-1"> Gateway </p>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm divide-y divide-gray-100">
{skillsCatalog.length > 0 ? skillsCatalog.map((skill) => (
<div key={skill.id} className="p-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-gray-900">{skill.name}</div>
<div className="text-xs text-gray-500 mt-1 break-all">{skill.path}</div>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${skill.source === 'builtin' ? 'bg-blue-50 text-blue-600' : 'bg-gray-100 text-gray-500'}`}>
{skill.source === 'builtin' ? '内置' : '额外'}
</span>
</div>
</div>
)) : (
<div className="bg-gray-50 rounded-xl p-8 text-center">
<p className="text-sm text-gray-400"></p>
<p className="text-xs text-gray-300 mt-1"> Gateway </p>
</div>
)}
</div>
</div>
);

View File

@@ -20,14 +20,14 @@ export function UsageStats() {
};
return (
<div>
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<button onClick={() => loadUsageStats()} className="text-sm text-gray-500 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50">
<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>
<p className="text-sm text-gray-500 mb-6"> Token </p>
<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} />
@@ -35,25 +35,29 @@ export function UsageStats() {
<StatCard label="总 Token" value={formatTokens(stats.totalTokens)} />
</div>
<h2 className="text-sm font-medium text-gray-700 mb-3"></h2>
<div className="bg-gray-50 rounded-xl p-5 space-y-4">
<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 && (
<p className="text-sm text-gray-400 text-center py-4"></p>
<div className="p-4 text-sm text-gray-400 text-center"></div>
)}
{models.map(([model, data]) => {
const total = data.inputTokens + data.outputTokens;
const maxTokens = Math.max(...models.map(([, d]) => d.inputTokens + d.outputTokens), 1);
const pct = (total / maxTokens) * 100;
// 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}>
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-medium text-gray-900">{model}</span>
<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="w-full bg-gray-200 rounded-full h-2 mb-1">
<div className="bg-orange-500 h-2 rounded-full" style={{ width: `${pct}%` }} />
<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-400">
<div className="flex justify-between text-xs text-gray-500">
<span>: {formatTokens(data.inputTokens)}</span>
<span>: {formatTokens(data.outputTokens)}</span>
<span>: {formatTokens(total)}</span>
@@ -68,9 +72,9 @@ export function UsageStats() {
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-gray-50 rounded-xl p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{value}</div>
<div className="text-xs text-gray-500 mt-1">{label}</div>
<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>
);
}

View File

@@ -1,82 +1,115 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
export function Workspace() {
const {
quickConfig,
workspaceInfo,
loadWorkspaceInfo,
saveQuickConfig,
} = useGatewayStore();
const [projectDir, setProjectDir] = useState('~/.openclaw/zclaw-workspace');
const [restrictFiles, setRestrictFiles] = useState(true);
const [autoSaveContext, setAutoSaveContext] = useState(true);
const [fileWatching, setFileWatching] = useState(true);
useEffect(() => {
loadWorkspaceInfo().catch(() => {});
}, []);
useEffect(() => {
setProjectDir(quickConfig.workspaceDir || workspaceInfo?.path || '~/.openclaw/zclaw-workspace');
}, [quickConfig.workspaceDir, workspaceInfo?.path]);
const handleWorkspaceBlur = async () => {
const nextValue = projectDir.trim() || '~/.openclaw/zclaw-workspace';
setProjectDir(nextValue);
await saveQuickConfig({ workspaceDir: nextValue });
await loadWorkspaceInfo();
};
const handleToggle = async (
key: 'restrictFiles' | 'autoSaveContext' | 'fileWatching',
value: boolean
) => {
await saveQuickConfig({ [key]: value });
};
const restrictFiles = quickConfig.restrictFiles ?? true;
const autoSaveContext = quickConfig.autoSaveContext ?? true;
const fileWatching = quickConfig.fileWatching ?? true;
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-sm text-gray-500 mb-6"></p>
<div className="max-w-3xl">
<h1 className="text-xl font-bold mb-2 text-gray-900"></h1>
<div className="text-xs text-gray-500 mb-6"></div>
<div className="bg-gray-50 rounded-xl p-5 mb-6">
<h2 className="text-sm font-bold text-gray-900 mb-1"></h2>
<p className="text-xs text-gray-500 mb-3">ZCLAW </p>
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6 shadow-sm">
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="text-xs text-gray-500 mb-3">ZCLAW </div>
<div className="flex gap-2">
<input
type="text"
value={projectDir}
onChange={(e) => setProjectDir(e.target.value)}
className="flex-1 bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-orange-500"
onBlur={() => { handleWorkspaceBlur().catch(() => {}); }}
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none"
/>
<button className="border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-100"></button>
<button className="text-xs px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
</button>
</div>
<div className="mt-3 space-y-1 text-xs text-gray-500">
<div>{workspaceInfo?.resolvedPath || '未解析'}</div>
<div>{workspaceInfo?.fileCount ?? 0}{workspaceInfo?.totalSize ?? 0} bytes</div>
</div>
</div>
<div className="space-y-4">
<ToggleCard
title="限制文件访问范围"
description="开启后Agent 的工作空间将限制在工作目录内。关闭后可访问更大范围,可能导致数据操作。无论开关状态,均建议提前备份重要文件。注意:受技术限制,我们无法保证完全阻止目录外执行或由此带来的外部影响;请自行评估风险并谨慎使用。"
checked={restrictFiles}
onChange={setRestrictFiles}
highlight
/>
<ToggleCard
title="自动保存上下文"
description="自动将聊天记录和提取的产物保存到本地工作区文件夹。"
checked={autoSaveContext}
onChange={setAutoSaveContext}
/>
<ToggleCard
title="文件监听"
description="监听本地文件变更,实时更新 Agent 上下文。"
checked={fileWatching}
onChange={setFileWatching}
/>
</div>
<div className="mt-6 bg-gray-50 rounded-xl p-5">
<div className="flex justify-between items-center">
<div>
<h2 className="text-sm font-bold text-gray-900"> OpenClaw </h2>
<p className="text-xs text-gray-500 mt-1"> OpenClaw ZCLAW</p>
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6 shadow-sm">
<div className="flex justify-between items-start">
<div className="flex-1 pr-4">
<div className="font-medium text-gray-900 mb-1">访</div>
<div className="text-xs text-gray-500 leading-relaxed">
Agent 访使
</div>
</div>
<button className="border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-100"></button>
<Toggle checked={restrictFiles} onChange={(value) => { handleToggle('restrictFiles', value).catch(() => {}); }} />
</div>
<div className="flex justify-between items-center py-3 border-t border-gray-100">
<div>
<div className="font-medium text-gray-900 mb-1"></div>
<div className="text-xs text-gray-500"></div>
</div>
<Toggle checked={autoSaveContext} onChange={(value) => { handleToggle('autoSaveContext', value).catch(() => {}); }} />
</div>
<div className="flex justify-between items-center py-3 border-t border-gray-100">
<div>
<div className="font-medium text-gray-900 mb-1"></div>
<div className="text-xs text-gray-500"> Agent </div>
</div>
<Toggle checked={fileWatching} onChange={(value) => { handleToggle('fileWatching', value).catch(() => {}); }} />
</div>
<div className="flex justify-between items-center py-3 border-t border-gray-100">
<div>
<div className="font-medium text-gray-900 mb-1"> OpenClaw </div>
<div className="text-xs text-gray-500"> OpenClaw ZCLAW</div>
</div>
<button className="text-xs px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
</button>
</div>
</div>
</div>
);
}
function ToggleCard({ title, description, checked, onChange, highlight }: {
title: string; description: string; checked: boolean; onChange: (v: boolean) => void; highlight?: boolean;
}) {
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className={`rounded-xl p-5 ${highlight ? 'bg-orange-50 border border-orange-200' : 'bg-gray-50'}`}>
<div className="flex justify-between items-start">
<div className="flex-1 mr-4">
<h3 className={`text-sm font-bold ${highlight ? 'text-orange-700' : 'text-gray-900'}`}>{title}</h3>
<p className="text-xs text-gray-500 mt-1 leading-relaxed">{description}</p>
</div>
<button
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 mt-0.5 ${checked ? 'bg-orange-500' : 'bg-gray-300'}`}
>
<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>
</div>
</div>
<button
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full transition-colors relative flex-shrink-0 mt-1 ${checked ? 'bg-orange-500' : 'bg-gray-300'}`}
>
<span className={`block w-5 h-5 bg-white rounded-full shadow-sm absolute top-0.5 transition-all ${checked ? 'left-5' : 'left-0.5'}`} />
</button>
);
}

View File

@@ -1,29 +1,46 @@
import { useState } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { Settings, MessageSquare, Clock, Bot, Radio } from 'lucide-react';
import { Settings } from 'lucide-react';
import { CloneManager } from './CloneManager';
import { ConversationList } from './ConversationList';
import { ChannelList } from './ChannelList';
import { HandList } from './HandList';
import { TaskList } from './TaskList';
import { useGatewayStore } from '../store/gatewayStore';
export type MainViewType = 'chat' | 'hands' | 'workflow';
interface SidebarProps {
onOpenSettings?: () => void;
onMainViewChange?: (view: MainViewType) => void;
selectedHandId?: string;
onSelectHand?: (handId: string) => void;
}
type Tab = 'chats' | 'clones' | 'channels' | 'tasks';
type Tab = 'clones' | 'hands' | 'workflow';
const TABS: { key: Tab; label: string; icon: typeof MessageSquare }[] = [
{ key: 'chats', label: '对话', icon: MessageSquare },
{ key: 'clones', label: '分身', icon: Bot },
{ key: 'channels', label: '频道', icon: Radio },
{ key: 'tasks', label: '任务', icon: Clock },
const TABS: { key: Tab; label: string; mainView?: MainViewType }[] = [
{ key: 'clones', label: '分身' },
{ key: 'hands', label: 'HANDS', mainView: 'hands' },
{ key: 'workflow', label: 'Workflow', mainView: 'workflow' },
];
export function Sidebar({ onOpenSettings }: SidebarProps) {
const { connectionState } = useGatewayStore();
const [activeTab, setActiveTab] = useState<Tab>('chats');
export function Sidebar({ onOpenSettings, onMainViewChange, selectedHandId, onSelectHand }: SidebarProps) {
const [activeTab, setActiveTab] = useState<Tab>('clones');
const userName = useGatewayStore((state) => state.quickConfig.userName) || '用户7141';
const connected = connectionState === 'connected';
const handleTabClick = (key: Tab, mainView?: MainViewType) => {
setActiveTab(key);
if (mainView && onMainViewChange) {
onMainViewChange(mainView);
} else if (onMainViewChange) {
onMainViewChange('chat');
}
};
const handleSelectHand = (handId: string) => {
onSelectHand?.(handId);
// 切换到 hands 视图
setActiveTab('hands');
onMainViewChange?.('hands');
};
return (
<aside className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
@@ -32,12 +49,12 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
{TABS.map(({ key, label }) => (
<button
key={key}
className={`flex-1 py-3 px-2 text-xs font-medium transition-colors ${
className={`flex-1 py-3 px-4 text-xs font-medium transition-colors ${
activeTab === key
? 'text-orange-600 border-b-2 border-orange-500'
? 'text-gray-900 border-b-2 border-gray-900'
: 'text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab(key)}
onClick={() => handleTabClick(key, TABS.find(t => t.key === key)?.mainView)}
>
{label}
</button>
@@ -46,25 +63,24 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{activeTab === 'chats' && <ConversationList />}
{activeTab === 'clones' && <CloneManager />}
{activeTab === 'channels' && <ChannelList onOpenSettings={onOpenSettings} />}
{activeTab === 'tasks' && <TaskList />}
{activeTab === 'hands' && (
<HandList
selectedHandId={selectedHandId}
onSelectHand={handleSelectHand}
/>
)}
{activeTab === 'workflow' && <TaskList />}
</div>
{/* 底部用户 */}
<div className="p-3 border-t border-gray-200 bg-gray-50">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
🦞
</div>
<div className="flex-1 min-w-0">
<span className="font-medium text-gray-700 text-sm">7141</span>
<div className={`text-xs ${connected ? 'text-green-500' : 'text-gray-400'}`}>
{connected ? '已连接' : '未连接'}
</div>
</div>
<button className="text-gray-400 hover:text-gray-600" onClick={onOpenSettings}>
<span className="font-medium text-gray-700">{userName}</span>
<button className="ml-auto text-gray-400 hover:text-gray-600" onClick={onOpenSettings}>
<Settings className="w-4 h-4" />
</button>
</div>

View File

@@ -0,0 +1,173 @@
/**
* TriggersPanel - OpenFang Triggers Management UI
*
* Displays available OpenFang Triggers and allows toggling them on/off.
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import type { Trigger } from '../store/gatewayStore';
interface TriggerCardProps {
trigger: Trigger;
onToggle: (id: string, enabled: boolean) => Promise<void>;
isToggling: boolean;
}
function TriggerCard({ trigger, onToggle, isToggling }: 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: '消息触发',
};
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="flex items-center justify-between">
<div className="flex-1">
<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 ? '已启用' : '已禁用'} />
</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}
</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">
<button
onClick={handleToggle}
disabled={isToggling}
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' : ''}`}
title={trigger.enabled ? '点击禁用' : '点击启用'}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
trigger.enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
);
}
export function TriggersPanel() {
const { triggers, loadTriggers, isLoading, client } = useGatewayStore();
const [togglingTrigger, setTogglingTrigger] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
loadTriggers();
}, [loadTriggers]);
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);
} finally {
setTogglingTrigger(null);
}
}, [client, loadTriggers]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
await loadTriggers();
} finally {
setRefreshing(false);
}
}, [loadTriggers]);
if (isLoading && triggers.length === 0) {
return (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
...
</div>
);
}
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>
<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="grid gap-3">
{triggers.map((trigger) => (
<TriggerCard
key={trigger.id}
trigger={trigger}
onToggle={handleToggle}
isToggling={togglingTrigger === trigger.id}
/>
))}
</div>
</div>
);
}
export default TriggersPanel;

View File

@@ -0,0 +1,445 @@
/**
* WorkflowList - OpenFang Workflow Management UI
*
* Displays available OpenFang Workflows and allows executing them.
*
* Design based on OpenFang Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import type { Workflow } from '../store/gatewayStore';
import {
Play,
Edit,
Trash2,
History,
Plus,
List,
GitBranch,
RefreshCw,
Loader2,
X,
} from 'lucide-react';
// === View Toggle Types ===
type ViewMode = 'list' | 'visual';
// === Workflow Execute Modal ===
interface ExecuteModalProps {
workflow: Workflow;
isOpen: boolean;
onClose: () => void;
onExecute: (id: string, input?: Record<string, unknown>) => Promise<void>;
isExecuting: boolean;
}
function ExecuteModal({ workflow, isOpen, onClose, onExecute, isExecuting }: ExecuteModalProps) {
const [input, setInput] = useState('');
const handleExecute = async () => {
let parsedInput: Record<string, unknown> | undefined;
if (input.trim()) {
try {
parsedInput = JSON.parse(input);
} catch {
alert('输入格式错误,请使用有效的 JSON 格式。');
return;
}
}
await onExecute(workflow.id, parsedInput);
setInput('');
onClose();
};
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-md mx-4 overflow-hidden">
{/* 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-3">
<div className="w-8 h-8 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<Play className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{workflow.name}</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="p-4">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
(JSON ):
</div>
<textarea
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder='{"key": "value"}'
value={input}
onChange={(e) => setInput(e.target.value)}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
</button>
<button
onClick={handleExecute}
disabled={isExecuting}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isExecuting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Play className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
// === Workflow Table Row ===
interface WorkflowRowProps {
workflow: Workflow;
onExecute: (workflow: Workflow) => void;
onEdit: (workflow: Workflow) => void;
onDelete: (workflow: Workflow) => void;
onHistory: (workflow: Workflow) => void;
isExecuting: boolean;
}
function WorkflowRow({ workflow, onExecute, onEdit, onDelete, onHistory, isExecuting }: WorkflowRowProps) {
// Format created date if available
const createdDate = workflow.createdAt
? new Date(workflow.createdAt).toLocaleDateString('zh-CN')
: new Date().toLocaleDateString('zh-CN');
return (
<tr className="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<GitBranch className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-white truncate">
{workflow.name}
</div>
{workflow.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{workflow.description}
</div>
)}
</div>
</div>
</td>
{/* Steps */}
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded-full text-sm font-medium text-gray-700 dark:text-gray-300">
{workflow.steps}
</span>
</td>
{/* Created */}
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{createdDate}
</td>
{/* Actions */}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onExecute(workflow)}
disabled={isExecuting}
className="p-1.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title="Run"
>
{isExecuting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
<button
onClick={() => onEdit(workflow)}
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
title="Edit"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => onHistory(workflow)}
className="p-1.5 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
title="History"
>
<History className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(workflow)}
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
}
// === Main WorkflowList Component ===
export function WorkflowList() {
const { workflows, loadWorkflows, executeWorkflow, isLoading } = useGatewayStore();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
const [showExecuteModal, setShowExecuteModal] = useState(false);
useEffect(() => {
loadWorkflows();
}, [loadWorkflows]);
const handleExecute = useCallback(async (id: string, input?: Record<string, unknown>) => {
setExecutingWorkflowId(id);
try {
await executeWorkflow(id, input);
} finally {
setExecutingWorkflowId(null);
}
}, [executeWorkflow]);
const handleExecuteClick = useCallback((workflow: Workflow) => {
setSelectedWorkflow(workflow);
setShowExecuteModal(true);
}, []);
const handleEdit = useCallback((workflow: Workflow) => {
// TODO: Implement workflow editor
console.log('Edit workflow:', workflow.id);
alert('工作流编辑器即将推出!');
}, []);
const handleDelete = useCallback((workflow: Workflow) => {
// TODO: Implement workflow deletion
console.log('Delete workflow:', workflow.id);
if (confirm(`确定要删除 "${workflow.name}" 吗?`)) {
alert('工作流删除功能即将推出!');
}
}, []);
const handleHistory = useCallback((workflow: Workflow) => {
// TODO: Implement workflow history view
console.log('View history:', workflow.id);
alert('工作流历史功能即将推出!');
}, []);
const handleNewWorkflow = useCallback(() => {
// TODO: Implement new workflow creation
alert('工作流构建器即将推出!');
}, []);
const handleCloseModal = useCallback(() => {
setShowExecuteModal(false);
setSelectedWorkflow(null);
}, []);
// Loading state
if (isLoading && workflows.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">...</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
</p>
</div>
<button
onClick={() => loadWorkflows()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Toolbar */}
<div className="flex items-center justify-between">
{/* View Toggle */}
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'list'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<List className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setViewMode('visual')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'visual'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<GitBranch className="w-3.5 h-3.5" />
</button>
</div>
{/* New Workflow Button */}
<button
onClick={handleNewWorkflow}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Content */}
{viewMode === 'list' ? (
workflows.length === 0 ? (
// Empty State
<div className="p-8 text-center">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
<GitBranch className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mb-4">
</p>
<button
onClick={handleNewWorkflow}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
) : (
// Table View
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{workflows.map((workflow) => (
<WorkflowRow
key={workflow.id}
workflow={workflow}
onExecute={handleExecuteClick}
onEdit={handleEdit}
onDelete={handleDelete}
onHistory={handleHistory}
isExecuting={executingWorkflowId === workflow.id}
/>
))}
</tbody>
</table>
</div>
)
) : (
// Visual Builder View (placeholder)
<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-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-3">
<GitBranch className="w-6 h-6 text-gray-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">
</p>
</div>
)}
{/* Execute Modal */}
{selectedWorkflow && (
<ExecuteModal
workflow={selectedWorkflow}
isOpen={showExecuteModal}
onClose={handleCloseModal}
onExecute={handleExecute}
isExecuting={executingWorkflowId === selectedWorkflow.id}
/>
)}
</div>
);
}
export default WorkflowList;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
/**
* OpenFang Gateway Configuration Types
*
* Types for gateway configuration and model choices.
*/
export interface GatewayModelChoice {
id: string;
name: string;
provider?: string;
contextWindow?: number;
maxOutput?: number;
}
export interface GatewayConfigSnapshot {
agentName?: string;
agentRole?: string;
userName?: string;
userRole?: string;
model?: string;
workspaceDir?: string;
gatewayUrl?: string;
gatewayToken?: string;
skillsExtraDirs?: string[];
mcpServices?: Array<{ id: string; name: string; enabled: boolean }>;
theme?: 'light' | 'dark';
autoStart?: boolean;
showToolCalls?: boolean;
restrictFiles?: boolean;
autoSaveContext?: boolean;
fileWatching?: boolean;
privacyOptIn?: boolean;
}

View File

@@ -0,0 +1,218 @@
import { invoke } from '@tauri-apps/api/core';
export interface LocalGatewayStatus {
supported: boolean;
cliAvailable: boolean;
runtimeSource: string | null;
runtimePath: string | null;
serviceLabel: string | null;
serviceLoaded: boolean;
serviceStatus: string | null;
configOk: boolean;
port: number | null;
portStatus: string | null;
probeUrl: string | null;
listenerPids: number[];
error: string | null;
raw: Record<string, unknown>;
}
export interface LocalGatewayAuth {
configPath: string | null;
gatewayToken: string | null;
}
export interface LocalGatewayPrepareResult {
configPath: string | null;
originsUpdated: boolean;
gatewayRestarted: boolean;
}
export interface LocalGatewayPairingApprovalResult {
approved: boolean;
requestId: string | null;
deviceId: string | null;
}
function buildFallbackStatus(supported: boolean, error: string | null = null): LocalGatewayStatus {
return {
supported,
cliAvailable: false,
runtimeSource: null,
runtimePath: null,
serviceLabel: null,
serviceLoaded: false,
serviceStatus: null,
configOk: false,
port: null,
portStatus: null,
probeUrl: null,
listenerPids: [],
error,
raw: {},
};
}
export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
async function callLocalGateway(command: string): Promise<LocalGatewayStatus> {
if (!isTauriRuntime()) {
return buildFallbackStatus(false);
}
return invoke<LocalGatewayStatus>(command);
}
export function getUnsupportedLocalGatewayStatus(): LocalGatewayStatus {
return buildFallbackStatus(false);
}
export async function getLocalGatewayStatus(): Promise<LocalGatewayStatus> {
return callLocalGateway('gateway_status');
}
export async function startLocalGateway(): Promise<LocalGatewayStatus> {
return callLocalGateway('gateway_start');
}
export async function stopLocalGateway(): Promise<LocalGatewayStatus> {
return callLocalGateway('gateway_stop');
}
export async function restartLocalGateway(): Promise<LocalGatewayStatus> {
return callLocalGateway('gateway_restart');
}
export async function getLocalGatewayAuth(): Promise<LocalGatewayAuth> {
if (!isTauriRuntime()) {
return {
configPath: null,
gatewayToken: null,
};
}
return invoke<LocalGatewayAuth>('gateway_local_auth');
}
export async function prepareLocalGatewayForTauri(): Promise<LocalGatewayPrepareResult> {
if (!isTauriRuntime()) {
return {
configPath: null,
originsUpdated: false,
gatewayRestarted: false,
};
}
return invoke<LocalGatewayPrepareResult>('gateway_prepare_for_tauri');
}
export async function approveLocalGatewayDevicePairing(deviceId: string, publicKeyBase64: string, url?: string): Promise<LocalGatewayPairingApprovalResult> {
if (!isTauriRuntime()) {
return {
approved: false,
requestId: null,
deviceId: null,
};
}
return invoke<LocalGatewayPairingApprovalResult>('gateway_approve_device_pairing', {
deviceId,
publicKeyBase64,
url,
});
}
// ============================================================================
// Process Monitoring Types and Functions
// ============================================================================
export interface ProcessInfo {
pid: number;
name: string;
status: string;
cpuPercent: number | null;
memoryMb: number | null;
uptimeSeconds: number | null;
}
export interface ProcessListResponse {
processes: ProcessInfo[];
totalCount: number;
runtimeSource: string | null;
}
export interface ProcessLogsResponse {
pid: number | null;
logs: string;
lines: number;
runtimeSource: string | null;
}
export interface VersionResponse {
version: string;
commit: string | null;
buildDate: string | null;
runtimeSource: string | null;
raw: Record<string, unknown>;
}
/**
* List OpenFang processes
* @returns List of running OpenFang processes with their status
*/
export async function getOpenFangProcessList(): Promise<ProcessListResponse> {
if (!isTauriRuntime()) {
return {
processes: [],
totalCount: 0,
runtimeSource: null,
};
}
return invoke<ProcessListResponse>('openfang_process_list');
}
/**
* Get OpenFang process logs
* @param pid - Optional process ID to get logs for. If not specified, gets main process logs.
* @param lines - Number of log lines to retrieve (default: 100)
* @returns Process logs
*/
export async function getOpenFangProcessLogs(
pid?: number,
lines?: number
): Promise<ProcessLogsResponse> {
if (!isTauriRuntime()) {
return {
pid: pid ?? null,
logs: '',
lines: 0,
runtimeSource: null,
};
}
return invoke<ProcessLogsResponse>('openfang_process_logs', {
pid,
lines,
});
}
/**
* Get OpenFang version information
* @returns Version information including version string, commit hash, and build date
*/
export async function getOpenFangVersion(): Promise<VersionResponse> {
if (!isTauriRuntime()) {
return {
version: 'unknown',
commit: null,
buildDate: null,
runtimeSource: null,
raw: {},
};
}
return invoke<VersionResponse>('openfang_version');
}

View File

@@ -2,16 +2,42 @@
import { persist } from 'zustand/middleware';
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
export interface MessageFile {
name: string;
path?: string;
size?: number;
type?: string;
}
export interface CodeBlock {
language?: string;
filename?: string;
content?: string;
}
export interface Message {
id: string;
role: 'user' | 'assistant' | 'tool';
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow';
content: string;
timestamp: Date;
runId?: string;
streaming?: boolean;
toolName?: string;
toolInput?: string;
toolOutput?: string;
error?: string;
// Hand event fields
handName?: string;
handStatus?: string;
handResult?: unknown;
// Workflow event fields
workflowId?: string;
workflowStep?: string;
workflowStatus?: string;
workflowResult?: unknown;
// Output files and code blocks
files?: MessageFile[];
codeBlocks?: CodeBlock[];
}
export interface Conversation {
@@ -19,6 +45,7 @@ export interface Conversation {
title: string;
messages: Message[];
sessionKey: string | null;
agentId: string | null;
createdAt: Date;
updatedAt: Date;
}
@@ -32,6 +59,13 @@ export interface Agent {
time: string;
}
export interface AgentProfileLike {
id: string;
name: string;
nickname?: string;
role?: string;
}
interface ChatState {
messages: Message[];
conversations: Conversation[];
@@ -45,6 +79,7 @@ interface ChatState {
addMessage: (message: Message) => void;
updateMessage: (id: string, updates: Partial<Message>) => void;
setCurrentAgent: (agent: Agent) => void;
syncAgents: (profiles: AgentProfileLike[]) => void;
setCurrentModel: (model: string) => void;
sendMessage: (content: string) => Promise<void>;
initStreamListener: () => () => void;
@@ -66,23 +101,83 @@ function deriveTitle(messages: Message[]): string {
return '新对话';
}
const DEFAULT_AGENT: Agent = {
id: '1',
name: 'ZCLAW',
icon: '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: '发送消息开始对话',
time: '',
};
export function toChatAgent(profile: AgentProfileLike): Agent {
return {
id: profile.id,
name: profile.name,
icon: profile.nickname?.slice(0, 1) || '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: profile.role || '新分身',
time: '',
};
}
function resolveConversationAgentId(agent: Agent | null): string | null {
if (!agent || agent.id === DEFAULT_AGENT.id) {
return null;
}
return agent.id;
}
function resolveGatewayAgentId(agent: Agent | null): string | undefined {
if (!agent || agent.id === DEFAULT_AGENT.id || agent.id.startsWith('clone_')) {
return undefined;
}
return agent.id;
}
function resolveAgentForConversation(agentId: string | null, agents: Agent[]): Agent {
if (!agentId) {
return DEFAULT_AGENT;
}
return agents.find((agent) => agent.id === agentId) || DEFAULT_AGENT;
}
function upsertActiveConversation(
conversations: Conversation[],
state: Pick<ChatState, 'messages' | 'sessionKey' | 'currentConversationId' | 'currentAgent'>
): Conversation[] {
if (state.messages.length === 0) {
return conversations;
}
const currentId = state.currentConversationId || generateConvId();
const existingIdx = conversations.findIndex((conversation) => conversation.id === currentId);
const nextConversation: Conversation = {
id: currentId,
title: deriveTitle(state.messages),
messages: [...state.messages],
sessionKey: state.sessionKey,
agentId: resolveConversationAgentId(state.currentAgent),
createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(),
updatedAt: new Date(),
};
if (existingIdx >= 0) {
conversations[existingIdx] = nextConversation;
return conversations;
}
return [nextConversation, ...conversations];
}
export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
messages: [],
conversations: [],
currentConversationId: null,
agents: [
{
id: '1',
name: 'ZCLAW',
icon: '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: '发送消息开始对话',
time: '',
},
],
currentAgent: null,
agents: [DEFAULT_AGENT],
currentAgent: DEFAULT_AGENT,
isStreaming: false,
currentModel: 'glm-5',
sessionKey: null,
@@ -97,32 +192,42 @@ export const useChatStore = create<ChatState>()(
),
})),
setCurrentAgent: (agent) => set({ currentAgent: agent }),
setCurrentAgent: (agent) =>
set((state) => {
if (state.currentAgent?.id === agent.id) {
return { currentAgent: agent };
}
const conversations = upsertActiveConversation([...state.conversations], state);
return {
conversations,
currentAgent: agent,
messages: [],
sessionKey: null,
isStreaming: false,
currentConversationId: null,
};
}),
syncAgents: (profiles) =>
set((state) => {
const agents = profiles.length > 0 ? profiles.map(toChatAgent) : [DEFAULT_AGENT];
const currentAgent = state.currentConversationId
? resolveAgentForConversation(
state.conversations.find((conversation) => conversation.id === state.currentConversationId)?.agentId || null,
agents
)
: state.currentAgent
? agents.find((agent) => agent.id === state.currentAgent?.id) || agents[0]
: agents[0];
return { agents, currentAgent };
}),
setCurrentModel: (model) => set({ currentModel: model }),
newConversation: () => {
const state = get();
let conversations = [...state.conversations];
// Save current conversation if it has messages
if (state.messages.length > 0) {
const currentId = state.currentConversationId || generateConvId();
const existingIdx = conversations.findIndex(c => c.id === currentId);
const conv: Conversation = {
id: currentId,
title: deriveTitle(state.messages),
messages: [...state.messages],
sessionKey: state.sessionKey,
createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(),
updatedAt: new Date(),
};
if (existingIdx >= 0) {
conversations[existingIdx] = conv;
} else {
conversations = [conv, ...conversations];
}
}
const conversations = upsertActiveConversation([...state.conversations], state);
set({
conversations,
@@ -135,21 +240,7 @@ export const useChatStore = create<ChatState>()(
switchConversation: (id: string) => {
const state = get();
let conversations = [...state.conversations];
// Save current conversation first
if (state.messages.length > 0 && state.currentConversationId) {
const existingIdx = conversations.findIndex(c => c.id === state.currentConversationId);
if (existingIdx >= 0) {
conversations[existingIdx] = {
...conversations[existingIdx],
messages: [...state.messages],
sessionKey: state.sessionKey,
updatedAt: new Date(),
title: deriveTitle(state.messages),
};
}
}
const conversations = upsertActiveConversation([...state.conversations], state);
const target = conversations.find(c => c.id === id);
if (target) {
@@ -157,6 +248,7 @@ export const useChatStore = create<ChatState>()(
conversations,
messages: [...target.messages],
sessionKey: target.sessionKey,
currentAgent: resolveAgentForConversation(target.agentId, state.agents),
currentConversationId: target.id,
isStreaming: false,
});
@@ -174,7 +266,9 @@ export const useChatStore = create<ChatState>()(
},
sendMessage: async (content: string) => {
const { addMessage, currentModel, sessionKey } = get();
const { addMessage, currentAgent, sessionKey } = get();
const effectiveSessionKey = sessionKey || `session_${Date.now()}`;
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
// Add user message
const userMsg: Message = {
@@ -199,22 +293,115 @@ export const useChatStore = create<ChatState>()(
try {
const client = getGatewayClient();
// Try streaming first (OpenFang WebSocket)
if (client.getState() === 'connected') {
const { runId } = await client.chatStream(
content,
{
onDelta: (delta: string) => {
set((state) => ({
messages: state.messages.map((m) =>
m.id === assistantId
? { ...m, content: m.content + delta }
: m
),
}));
},
onTool: (tool: string, input: string, output: string) => {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'tool',
content: output || input,
timestamp: new Date(),
runId,
toolName: tool,
toolInput: input,
toolOutput: output,
};
set((state) => ({ messages: [...state.messages, toolMsg] }));
},
onHand: (name: string, status: string, result?: unknown) => {
const handMsg: Message = {
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'hand',
content: result
? (typeof result === 'string' ? result : JSON.stringify(result, null, 2))
: `Hand: ${name} - ${status}`,
timestamp: new Date(),
runId,
handName: name,
handStatus: status,
handResult: result,
};
set((state) => ({ messages: [...state.messages, handMsg] }));
},
onComplete: () => {
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m
),
}));
},
onError: (error: string) => {
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ ${error}`, streaming: false, error }
: m
),
}));
},
},
{
sessionKey: effectiveSessionKey,
agentId: effectiveAgentId,
}
);
if (!sessionKey) {
set({ sessionKey: effectiveSessionKey });
}
// Store runId on the message for correlation
set((state) => ({
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, runId } : m
),
}));
return;
}
// Fallback to REST API (non-streaming)
const result = await client.chat(content, {
sessionKey: sessionKey || undefined,
model: currentModel,
sessionKey: effectiveSessionKey,
agentId: effectiveAgentId,
});
// Store session key for continuity
if (!sessionKey) {
set({ sessionKey: `session_${Date.now()}` });
set({ sessionKey: effectiveSessionKey });
}
// OpenFang returns response directly (no WebSocket streaming)
if (result.response) {
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId
? { ...m, content: result.response || '', streaming: false }
: m
),
}));
return;
}
// The actual streaming content comes via the 'agent' event listener
// set in initStreamListener(). The runId links events to this message.
// Store runId on the message for correlation
set((state) => ({
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, toolInput: result.runId } : m
m.id === assistantId ? { ...m, runId: result.runId } : m
),
}));
} catch (err: any) {
@@ -241,29 +428,37 @@ export const useChatStore = create<ChatState>()(
const unsubscribe = client.onAgentStream((delta: AgentStreamDelta) => {
const state = get();
// Find the currently streaming assistant message
const streamingMsg = [...state.messages]
.reverse()
.find((m) => m.role === 'assistant' && m.streaming);
.find((m) => (
m.role === 'assistant'
&& m.streaming
&& (
(delta.runId && m.runId === delta.runId)
|| (!delta.runId && m.runId == null)
)
))
|| [...state.messages]
.reverse()
.find((m) => m.role === 'assistant' && m.streaming);
if (!streamingMsg) return;
if (delta.stream === 'assistant' && delta.delta) {
// Append text delta to the streaming message
if (delta.stream === 'assistant' && (delta.delta || delta.content)) {
set((s) => ({
messages: s.messages.map((m) =>
m.id === streamingMsg.id
? { ...m, content: m.content + delta.delta }
? { ...m, content: m.content + (delta.delta || delta.content || '') }
: m
),
}));
} else if (delta.stream === 'tool') {
// Add a tool message
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'tool',
content: delta.toolOutput || '',
timestamp: new Date(),
runId: delta.runId,
toolName: delta.tool,
toolInput: delta.toolInput,
toolOutput: delta.toolOutput,
@@ -271,7 +466,6 @@ export const useChatStore = create<ChatState>()(
set((s) => ({ messages: [...s.messages, toolMsg] }));
} else if (delta.stream === 'lifecycle') {
if (delta.phase === 'end' || delta.phase === 'error') {
// Mark streaming complete
set((s) => ({
isStreaming: false,
messages: s.messages.map((m) =>
@@ -285,6 +479,37 @@ export const useChatStore = create<ChatState>()(
),
}));
}
} else if (delta.stream === 'hand') {
// Handle Hand trigger events from OpenFang
const handMsg: Message = {
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'hand',
content: delta.handResult
? (typeof delta.handResult === 'string' ? delta.handResult : JSON.stringify(delta.handResult, null, 2))
: `Hand: ${delta.handName || 'unknown'} - ${delta.handStatus || 'triggered'}`,
timestamp: new Date(),
runId: delta.runId,
handName: delta.handName,
handStatus: delta.handStatus,
handResult: delta.handResult,
};
set((s) => ({ messages: [...s.messages, handMsg] }));
} else if (delta.stream === 'workflow') {
// Handle Workflow execution events from OpenFang
const workflowMsg: Message = {
id: `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'workflow',
content: delta.workflowResult
? (typeof delta.workflowResult === 'string' ? delta.workflowResult : JSON.stringify(delta.workflowResult, null, 2))
: `Workflow: ${delta.workflowId || 'unknown'} step ${delta.workflowStep || '?'} - ${delta.workflowStatus || 'running'}`,
timestamp: new Date(),
runId: delta.runId,
workflowId: delta.workflowId,
workflowStep: delta.workflowStep,
workflowStatus: delta.workflowStatus,
workflowResult: delta.workflowResult,
};
set((s) => ({ messages: [...s.messages, workflowMsg] }));
}
});

View File

@@ -1,5 +1,7 @@
import { create } from 'zustand';
import { GatewayClient, ConnectionState, getGatewayClient } from '../lib/gateway-client';
import { create } from 'zustand';
import { DEFAULT_GATEWAY_URL, FALLBACK_GATEWAY_URLS, GatewayClient, ConnectionState, getGatewayClient, getLocalDeviceIdentity, getStoredGatewayToken, getStoredGatewayUrl, setStoredGatewayToken, setStoredGatewayUrl } from '../lib/gateway-client';
import { approveLocalGatewayDevicePairing, getLocalGatewayAuth, getLocalGatewayStatus, getUnsupportedLocalGatewayStatus, isTauriRuntime, prepareLocalGatewayForTauri, restartLocalGateway as restartLocalGatewayCommand, startLocalGateway as startLocalGatewayCommand, stopLocalGateway as stopLocalGatewayCommand, type LocalGatewayStatus } from '../lib/tauri-gateway';
import { useChatStore } from './chatStore';
interface GatewayLog {
timestamp: number;
@@ -14,7 +16,16 @@ interface Clone {
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
workspaceResolvedPath?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
createdAt: string;
bootstrapReady?: boolean;
bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>;
updatedAt?: string;
}
interface UsageStats {
@@ -43,12 +54,218 @@ interface ScheduledTask {
description?: string;
}
interface SkillInfo {
id: string;
name: string;
path: string;
source: 'builtin' | 'extra';
}
interface QuickConfig {
agentName?: string;
agentRole?: string;
userName?: string;
userRole?: string;
agentNickname?: string;
scenarios?: string[];
workspaceDir?: string;
gatewayUrl?: string;
gatewayToken?: string;
skillsExtraDirs?: string[];
mcpServices?: Array<{ id: string; name: string; enabled: boolean }>;
theme?: 'light' | 'dark';
autoStart?: boolean;
showToolCalls?: boolean;
restrictFiles?: boolean;
autoSaveContext?: boolean;
fileWatching?: boolean;
privacyOptIn?: boolean;
}
interface WorkspaceInfo {
path: string;
resolvedPath: string;
exists: boolean;
fileCount: number;
totalSize: number;
}
// === OpenFang Types ===
export interface HandRequirement {
description: string;
met: boolean;
details?: string;
}
export interface Hand {
id: string; // Hand ID used for API calls
name: string; // Display name
description: string;
status: 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
currentRunId?: string;
requirements_met?: boolean;
category?: string; // productivity, data, content, communication
icon?: string;
// Extended fields from details API
provider?: string;
model?: string;
requirements?: HandRequirement[];
tools?: string[];
metrics?: string[];
toolCount?: number;
metricCount?: number;
}
export interface HandRun {
runId: string;
status: string;
result?: unknown;
}
export interface Workflow {
id: string;
name: string;
steps: number;
description?: string;
}
export interface WorkflowRun {
runId: string;
status: string;
step?: string;
result?: unknown;
}
export interface Trigger {
id: string;
type: string;
enabled: boolean;
}
// === Scheduler Types ===
export interface ScheduledJob {
id: string;
name: string;
cron: string;
enabled: boolean;
handName?: string;
workflowId?: string;
lastRun?: string;
nextRun?: string;
}
export interface EventTrigger {
id: string;
name: string;
eventType: string;
enabled: boolean;
handName?: string;
workflowId?: string;
}
export interface RunHistoryEntry {
id: string;
type: 'scheduled_job' | 'event_trigger';
sourceId: string;
sourceName: string;
status: 'success' | 'failure' | 'running';
startedAt: string;
completedAt?: string;
duration?: number;
error?: string;
}
// === Approval Types ===
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired';
export interface Approval {
id: string;
handName: string;
runId?: string;
status: ApprovalStatus;
requestedAt: string;
requestedBy?: string;
reason?: string;
action?: string;
params?: Record<string, unknown>;
respondedAt?: string;
respondedBy?: string;
responseReason?: string;
}
export interface AuditLogEntry {
id: string;
timestamp: string;
action: string;
actor?: string;
result?: 'success' | 'failure';
details?: Record<string, unknown>;
}
// === Security Types ===
export interface SecurityLayer {
name: string;
enabled: boolean;
description?: string;
}
export interface SecurityStatus {
layers: SecurityLayer[];
enabledCount: number;
totalCount: number;
securityLevel: 'critical' | 'high' | 'medium' | 'low';
}
function shouldRetryGatewayCandidate(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error || '');
return (
message === 'WebSocket connection failed'
|| message.startsWith('Gateway handshake timed out')
|| message.startsWith('WebSocket closed before handshake completed')
);
}
function requiresLocalDevicePairing(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error || '');
return message.includes('pairing required');
}
function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' {
if (totalCount === 0) return 'low';
const ratio = enabledCount / totalCount;
if (ratio >= 0.875) return 'critical'; // 14-16 layers
if (ratio >= 0.625) return 'high'; // 10-13 layers
if (ratio >= 0.375) return 'medium'; // 6-9 layers
return 'low'; // 0-5 layers
}
function isLoopbackGatewayUrl(url: string): boolean {
return /^wss?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(url.trim());
}
async function approveCurrentLocalDevicePairing(url: string): Promise<boolean> {
if (!isTauriRuntime() || !isLoopbackGatewayUrl(url)) {
return false;
}
const identity = await getLocalDeviceIdentity();
const result = await approveLocalGatewayDevicePairing(identity.deviceId, identity.publicKeyBase64, url);
return result.approved;
}
interface GatewayStore {
// Connection state
connectionState: ConnectionState;
gatewayVersion: string | null;
error: string | null;
logs: GatewayLog[];
localGateway: LocalGatewayStatus;
localGatewayBusy: boolean;
isLoading: boolean;
// Data
clones: Clone[];
@@ -56,6 +273,17 @@ interface GatewayStore {
pluginStatus: any[];
channels: ChannelInfo[];
scheduledTasks: ScheduledTask[];
skillsCatalog: SkillInfo[];
quickConfig: QuickConfig;
workspaceInfo: WorkspaceInfo | null;
// OpenFang Data
hands: Hand[];
workflows: Workflow[];
triggers: Trigger[];
auditLogs: AuditLogEntry[];
securityStatus: SecurityStatus | null;
approvals: Approval[];
// Client reference
client: GatewayClient;
@@ -65,13 +293,62 @@ interface GatewayStore {
disconnect: () => void;
sendMessage: (message: string, sessionKey?: string) => Promise<{ runId: string }>;
loadClones: () => Promise<void>;
createClone: (opts: { name: string; role?: string; scenarios?: string[] }) => Promise<void>;
createClone: (opts: {
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
}) => Promise<Clone | undefined>;
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
deleteClone: (id: string) => Promise<void>;
loadUsageStats: () => Promise<void>;
loadPluginStatus: () => Promise<void>;
loadChannels: () => Promise<void>;
loadScheduledTasks: () => Promise<void>;
loadSkillsCatalog: () => Promise<void>;
loadQuickConfig: () => Promise<void>;
saveQuickConfig: (updates: Partial<QuickConfig>) => Promise<void>;
loadWorkspaceInfo: () => Promise<void>;
refreshLocalGateway: () => Promise<LocalGatewayStatus>;
startLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
stopLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
restartLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
clearLogs: () => void;
// OpenFang Actions
loadHands: () => Promise<void>;
getHandDetails: (name: string) => Promise<Hand | undefined>;
triggerHand: (name: string, params?: Record<string, unknown>) => Promise<HandRun | undefined>;
approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<void>;
cancelHand: (name: string, runId: string) => Promise<void>;
loadWorkflows: () => Promise<void>;
executeWorkflow: (id: string, input?: Record<string, unknown>) => Promise<WorkflowRun | undefined>;
cancelWorkflow: (id: string, runId: string) => Promise<void>;
loadTriggers: () => Promise<void>;
loadAuditLogs: (opts?: { limit?: number; offset?: number }) => Promise<void>;
loadSecurityStatus: () => Promise<void>;
loadApprovals: (status?: ApprovalStatus) => Promise<void>;
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<void>;
}
function normalizeGatewayUrlCandidate(url: string): string {
return url.trim().replace(/\/+$/, '');
}
function getLocalGatewayConnectUrl(status: LocalGatewayStatus): string | null {
if (status.probeUrl && status.probeUrl.trim()) {
return normalizeGatewayUrlCandidate(status.probeUrl);
}
if (status.port) {
return `ws://127.0.0.1:${status.port}`;
}
return null;
}
export const useGatewayStore = create<GatewayStore>((set, get) => {
@@ -93,24 +370,146 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
gatewayVersion: null,
error: null,
logs: [],
localGateway: getUnsupportedLocalGatewayStatus(),
localGatewayBusy: false,
isLoading: false,
clones: [],
usageStats: null,
pluginStatus: [],
channels: [],
scheduledTasks: [],
skillsCatalog: [],
quickConfig: {},
workspaceInfo: null,
// OpenFang state
hands: [],
workflows: [],
triggers: [],
auditLogs: [],
securityStatus: null,
approvals: [],
client,
connect: async (url?: string, token?: string) => {
const c = get().client;
const resolveCandidates = async (): Promise<string[]> => {
const explicitUrl = url?.trim();
if (explicitUrl) {
return [normalizeGatewayUrlCandidate(explicitUrl)];
}
const candidates: string[] = [];
if (isTauriRuntime()) {
try {
const localStatus = await getLocalGatewayStatus();
const localUrl = getLocalGatewayConnectUrl(localStatus);
if (localUrl) {
candidates.push(localUrl);
}
} catch {
/* ignore local gateway lookup failures during candidate selection */
}
}
const quickConfigGatewayUrl = get().quickConfig.gatewayUrl?.trim();
if (quickConfigGatewayUrl) {
candidates.push(quickConfigGatewayUrl);
}
candidates.push(getStoredGatewayUrl(), DEFAULT_GATEWAY_URL, ...FALLBACK_GATEWAY_URLS);
return Array.from(
new Set(
candidates
.filter(Boolean)
.map(normalizeGatewayUrlCandidate)
)
);
};
try {
set({ error: null });
const c = url ? getGatewayClient({ url, token }) : get().client;
await c.connect();
if (isTauriRuntime()) {
try {
await prepareLocalGatewayForTauri();
} catch {
/* ignore local gateway preparation failures during connection bootstrap */
}
}
// Use the first non-empty token from: param > quickConfig > localStorage
let effectiveToken = token || get().quickConfig.gatewayToken || getStoredGatewayToken();
if (!effectiveToken && isTauriRuntime()) {
try {
const localAuth = await getLocalGatewayAuth();
if (localAuth.gatewayToken) {
effectiveToken = localAuth.gatewayToken;
setStoredGatewayToken(localAuth.gatewayToken);
}
} catch {
/* ignore local auth lookup failures during connection bootstrap */
}
}
console.log('[GatewayStore] Connecting with token:', effectiveToken ? `${effectiveToken.substring(0, 8)}...` : '(empty)');
const candidateUrls = await resolveCandidates();
let lastError: unknown = null;
let connectedUrl: string | null = null;
for (const candidateUrl of candidateUrls) {
try {
c.updateOptions({
url: candidateUrl,
token: effectiveToken,
});
await c.connect();
connectedUrl = candidateUrl;
break;
} catch (err) {
lastError = err;
if (requiresLocalDevicePairing(err)) {
const approved = await approveCurrentLocalDevicePairing(candidateUrl);
if (approved) {
c.updateOptions({
url: candidateUrl,
token: effectiveToken,
});
await c.connect();
connectedUrl = candidateUrl;
break;
}
}
if (!shouldRetryGatewayCandidate(err)) {
throw err;
}
}
}
if (!connectedUrl) {
throw (lastError instanceof Error ? lastError : new Error('无法连接到任何可用 Gateway'));
}
setStoredGatewayUrl(connectedUrl);
// Fetch initial data after connection
try {
const health = await c.health();
set({ gatewayVersion: health?.version });
} catch { /* health may not return version */ }
await Promise.allSettled([
get().loadQuickConfig(),
get().loadWorkspaceInfo(),
get().loadClones(),
get().loadUsageStats(),
get().loadPluginStatus(),
get().loadScheduledTasks(),
get().loadSkillsCatalog(),
// OpenFang data loading
get().loadHands(),
get().loadWorkflows(),
get().loadTriggers(),
get().loadSecurityStatus(),
]);
await get().loadChannels();
} catch (err: any) {
set({ error: err.message });
throw err;
@@ -129,16 +528,42 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
loadClones: async () => {
try {
const result = await get().client.listClones();
set({ clones: result?.clones || [] });
const clones = result?.clones || result?.agents || [];
set({ clones });
useChatStore.getState().syncAgents(clones);
// Set default agent ID if we have agents and none is set
if (clones.length > 0 && clones[0].id) {
const client = get().client;
const currentDefault = client.getDefaultAgentId();
// Only set if the default doesn't exist in the list
const defaultExists = clones.some((c: any) => c.id === currentDefault);
if (!defaultExists) {
client.setDefaultAgentId(clones[0].id);
}
}
} catch { /* ignore if method not available */ }
},
createClone: async (opts) => {
try {
await get().client.createClone(opts);
const result = await get().client.createClone(opts);
await get().loadClones();
return result?.clone;
} catch (err: any) {
set({ error: err.message });
return undefined;
}
},
updateClone: async (id, updates) => {
try {
const result = await get().client.updateClone(id, updates);
await get().loadClones();
return result?.clone;
} catch (err: any) {
set({ error: err.message });
return undefined;
}
},
@@ -212,6 +637,345 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
} catch { /* ignore if heartbeat.tasks not available */ }
},
loadSkillsCatalog: async () => {
try {
const result = await get().client.listSkills();
set({ skillsCatalog: result?.skills || [] });
if (result?.extraDirs) {
set((state) => ({
quickConfig: {
...state.quickConfig,
skillsExtraDirs: result.extraDirs,
},
}));
}
} catch { /* ignore if skills list not available */ }
},
loadQuickConfig: async () => {
try {
const result = await get().client.getQuickConfig();
set({ quickConfig: result?.quickConfig || {} });
} catch { /* ignore if quick config not available */ }
},
saveQuickConfig: async (updates) => {
try {
const nextConfig = { ...get().quickConfig, ...updates };
if (nextConfig.gatewayUrl) {
setStoredGatewayUrl(nextConfig.gatewayUrl);
}
if (Object.prototype.hasOwnProperty.call(updates, 'gatewayToken')) {
setStoredGatewayToken(nextConfig.gatewayToken || '');
}
const result = await get().client.saveQuickConfig(nextConfig);
set({ quickConfig: result?.quickConfig || nextConfig });
} catch (err: any) {
set({ error: err.message });
}
},
loadWorkspaceInfo: async () => {
try {
const info = await get().client.getWorkspaceInfo();
set({ workspaceInfo: info });
} catch { /* ignore if workspace info not available */ }
},
refreshLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true });
try {
const status = await getLocalGatewayStatus();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: any) {
const message = err?.message || '读取本地 Gateway 状态失败';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return nextStatus;
}
},
startLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await startLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: any) {
const message = err?.message || '启动本地 Gateway 失败';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
stopLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await stopLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: any) {
const message = err?.message || '停止本地 Gateway 失败';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
restartLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await restartLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: any) {
const message = err?.message || '重启本地 Gateway 失败';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
// === OpenFang Actions ===
loadHands: async () => {
set({ isLoading: true });
try {
const result = await get().client.listHands();
// Map API response to Hand interface
const hands: Hand[] = (result?.hands || []).map(h => ({
id: h.id || h.name,
name: h.name,
description: h.description || '',
status: h.status || (h.requirements_met ? 'idle' : 'setup_needed'),
requirements_met: h.requirements_met,
category: h.category,
icon: h.icon,
toolCount: h.tool_count || h.tools?.length,
metricCount: h.metric_count || h.metrics?.length,
}));
set({ hands, isLoading: false });
} catch {
set({ isLoading: false });
/* ignore if hands API not available */
}
},
getHandDetails: async (name: string) => {
try {
const result = await get().client.getHand(name);
if (!result) return undefined;
// Map API response to extended Hand interface
const hand: Hand = {
id: result.id || result.name || name,
name: result.name || name,
description: result.description || '',
status: result.status || (result.requirements_met ? 'idle' : 'setup_needed'),
requirements_met: result.requirements_met,
category: result.category,
icon: result.icon,
provider: result.provider || result.config?.provider,
model: result.model || result.config?.model,
requirements: result.requirements?.map((r: any) => ({
description: r.description || r.name || String(r),
met: r.met ?? r.satisfied ?? true,
details: r.details || r.hint,
})),
tools: result.tools || result.config?.tools,
metrics: result.metrics || result.config?.metrics,
toolCount: result.tool_count || result.tools?.length || 0,
metricCount: result.metric_count || result.metrics?.length || 0,
};
// Update hands list with detailed info
set(state => ({
hands: state.hands.map(h => h.name === name ? { ...h, ...hand } : h),
}));
return hand;
} catch {
return undefined;
}
},
triggerHand: async (name: string, params?: Record<string, unknown>) => {
try {
const result = await get().client.triggerHand(name, params);
return result ? { runId: result.runId, status: result.status } : undefined;
} catch (err: any) {
set({ error: err.message });
return undefined;
}
},
approveHand: async (name: string, runId: string, approved: boolean, reason?: string) => {
try {
await get().client.approveHand(name, runId, approved, reason);
// Refresh hands to update status
await get().loadHands();
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
cancelHand: async (name: string, runId: string) => {
try {
await get().client.cancelHand(name, runId);
// Refresh hands to update status
await get().loadHands();
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
loadWorkflows: async () => {
set({ isLoading: true });
try {
const result = await get().client.listWorkflows();
set({ workflows: result?.workflows || [], isLoading: false });
} catch {
set({ isLoading: false });
/* ignore if workflows API not available */
}
},
executeWorkflow: async (id: string, input?: Record<string, unknown>) => {
try {
const result = await get().client.executeWorkflow(id, input);
return result ? { runId: result.runId, status: result.status } : undefined;
} catch (err: any) {
set({ error: err.message });
return undefined;
}
},
cancelWorkflow: async (id: string, runId: string) => {
try {
await get().client.cancelWorkflow(id, runId);
// Refresh workflows to update status
await get().loadWorkflows();
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
loadTriggers: async () => {
try {
const result = await get().client.listTriggers();
set({ triggers: result?.triggers || [] });
} catch { /* ignore if triggers API not available */ }
},
loadAuditLogs: async (opts?: { limit?: number; offset?: number }) => {
try {
const result = await get().client.getAuditLogs(opts);
set({ auditLogs: (result?.logs || []) as AuditLogEntry[] });
} catch { /* ignore if audit API not available */ }
},
loadSecurityStatus: async () => {
try {
const result = await get().client.getSecurityStatus();
if (result?.layers) {
const layers = result.layers as SecurityLayer[];
const enabledCount = layers.filter(l => l.enabled).length;
const totalCount = layers.length;
const securityLevel = calculateSecurityLevel(enabledCount, totalCount);
set({
securityStatus: {
layers,
enabledCount,
totalCount,
securityLevel,
},
});
}
} catch { /* ignore if security API not available */ }
},
loadApprovals: async (status?: ApprovalStatus) => {
try {
const result = await get().client.listApprovals(status);
const approvals: Approval[] = (result?.approvals || []).map((a: any) => ({
id: a.id || a.approval_id,
handName: a.hand_name || a.handName,
runId: a.run_id || a.runId,
status: a.status || 'pending',
requestedAt: a.requested_at || a.requestedAt || new Date().toISOString(),
requestedBy: a.requested_by || a.requestedBy,
reason: a.reason || a.description,
action: a.action || 'execute',
params: a.params,
respondedAt: a.responded_at || a.respondedAt,
respondedBy: a.responded_by || a.respondedBy,
responseReason: a.response_reason || a.responseReason,
}));
set({ approvals });
} catch { /* ignore if approvals API not available */ }
},
respondToApproval: async (approvalId: string, approved: boolean, reason?: string) => {
try {
await get().client.respondToApproval(approvalId, approved, reason);
// Refresh approvals after response
await get().loadApprovals();
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
clearLogs: () => set({ logs: [] }),
};
});

208
desktop/src/types/hands.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* OpenFang Hands and Workflow Types
*
* OpenFang provides 7 autonomous capability packages (Hands):
* - Clip: Video processing
* - Lead: Sales lead management
* - Collector: Data collection
* - Predictor: Predictive analytics
* - Researcher: Deep research
* - Twitter: Twitter automation
* - Browser: Browser automation
*/
export type HandStatus = 'idle' | 'running' | 'needs_approval' | 'completed' | 'error';
export type HandId = 'clip' | 'lead' | 'collector' | 'predictor' | 'researcher' | 'twitter' | 'browser';
export interface HandParameter {
name: string;
label: string;
type: 'text' | 'number' | 'select' | 'textarea' | 'boolean';
required: boolean;
placeholder?: string;
options?: Array<{ value: string; label: string }>;
defaultValue?: string | number | boolean;
description?: string;
}
export interface Hand {
id: HandId;
name: string;
description: string;
icon: string;
status: HandStatus;
parameters?: HandParameter[];
lastRun?: string;
lastResult?: string;
error?: string;
}
export interface HandExecutionResult {
handId: HandId;
runId: string;
status: 'success' | 'error' | 'needs_approval';
output?: Record<string, unknown>;
error?: string;
completedAt: string;
}
export interface WorkflowStep {
handId: HandId;
name: string;
parameters?: Record<string, unknown>;
condition?: string;
}
export type WorkflowStatus = 'idle' | 'running' | 'completed' | 'error' | 'paused';
export interface Workflow {
id: string;
name: string;
description: string;
steps: WorkflowStep[];
status: WorkflowStatus;
currentStep?: number;
lastRun?: string;
lastResult?: string;
error?: string;
createdAt: string;
updatedAt: string;
}
export interface WorkflowExecutionResult {
workflowId: string;
runId: string;
status: 'success' | 'error' | 'partial';
stepResults: Array<{
stepIndex: number;
handId: HandId;
status: 'success' | 'error' | 'skipped';
output?: Record<string, unknown>;
error?: string;
}>;
completedAt: string;
}
// Hand definitions with metadata
export const HAND_DEFINITIONS: Array<Omit<Hand, 'status' | 'lastRun' | 'lastResult' | 'error'>> = [
{
id: 'clip',
name: 'Clip',
description: 'Video processing and editing automation',
icon: 'Video',
parameters: [
{ name: 'inputPath', label: 'Input Path', type: 'text', required: true, placeholder: 'Video file or URL' },
{ name: 'outputFormat', label: 'Output Format', type: 'select', required: false, options: [
{ value: 'mp4', label: 'MP4' },
{ value: 'webm', label: 'WebM' },
{ value: 'gif', label: 'GIF' },
], defaultValue: 'mp4' },
{ name: 'trimStart', label: 'Start Time', type: 'number', required: false, placeholder: 'Seconds' },
{ name: 'trimEnd', label: 'End Time', type: 'number', required: false, placeholder: 'Seconds' },
],
},
{
id: 'lead',
name: 'Lead',
description: 'Sales lead generation and management',
icon: 'UserPlus',
parameters: [
{ name: 'source', label: 'Data Source', type: 'select', required: true, options: [
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'crunchbase', label: 'Crunchbase' },
{ value: 'custom', label: 'Custom List' },
] },
{ name: 'query', label: 'Search Query', type: 'textarea', required: true, placeholder: 'Enter search criteria' },
{ name: 'maxResults', label: 'Max Results', type: 'number', required: false, defaultValue: 50 },
],
},
{
id: 'collector',
name: 'Collector',
description: 'Automated data collection and aggregation',
icon: 'Database',
parameters: [
{ name: 'targetUrl', label: 'Target URL', type: 'text', required: true, placeholder: 'URL to scrape' },
{ name: 'selector', label: 'CSS Selector', type: 'text', required: false, placeholder: 'Elements to extract' },
{ name: 'outputFormat', label: 'Output Format', type: 'select', required: false, options: [
{ value: 'json', label: 'JSON' },
{ value: 'csv', label: 'CSV' },
{ value: 'xlsx', label: 'Excel' },
], defaultValue: 'json' },
{ name: 'pagination', label: 'Follow Pagination', type: 'boolean', required: false, defaultValue: false },
],
},
{
id: 'predictor',
name: 'Predictor',
description: 'Predictive analytics and forecasting',
icon: 'TrendingUp',
parameters: [
{ name: 'dataSource', label: 'Data Source', type: 'text', required: true, placeholder: 'Data file path or URL' },
{ name: 'model', label: 'Model Type', type: 'select', required: true, options: [
{ value: 'regression', label: 'Regression' },
{ value: 'classification', label: 'Classification' },
{ value: 'timeseries', label: 'Time Series' },
] },
{ name: 'targetColumn', label: 'Target Column', type: 'text', required: true },
],
},
{
id: 'researcher',
name: 'Researcher',
description: 'Deep research and analysis automation',
icon: 'Search',
parameters: [
{ name: 'topic', label: 'Research Topic', type: 'textarea', required: true, placeholder: 'Enter research topic' },
{ name: 'depth', label: 'Research Depth', type: 'select', required: false, options: [
{ value: 'shallow', label: 'Quick Overview' },
{ value: 'medium', label: 'Standard Research' },
{ value: 'deep', label: 'Comprehensive Analysis' },
], defaultValue: 'medium' },
{ name: 'sources', label: 'Max Sources', type: 'number', required: false, defaultValue: 10 },
],
},
{
id: 'twitter',
name: 'Twitter',
description: 'Twitter/X automation and engagement',
icon: 'Twitter',
parameters: [
{ name: 'action', label: 'Action Type', type: 'select', required: true, options: [
{ value: 'post', label: 'Post Tweet' },
{ value: 'search', label: 'Search Tweets' },
{ value: 'analyze', label: 'Analyze Trends' },
{ value: 'engage', label: 'Engage (Like/Reply)' },
] },
{ name: 'content', label: 'Content', type: 'textarea', required: false, placeholder: 'Tweet content or search query' },
{ name: 'schedule', label: 'Schedule Time', type: 'text', required: false, placeholder: 'ISO datetime or "now"' },
],
},
{
id: 'browser',
name: 'Browser',
description: 'Browser automation and web interaction',
icon: 'Globe',
parameters: [
{ name: 'url', label: 'Starting URL', type: 'text', required: true, placeholder: 'https://example.com' },
{ name: 'actions', label: 'Actions', type: 'textarea', required: true, placeholder: 'List of actions to perform' },
{ name: 'headless', label: 'Headless Mode', type: 'boolean', required: false, defaultValue: true },
{ name: 'timeout', label: 'Timeout (seconds)', type: 'number', required: false, defaultValue: 30 },
],
},
];
// Helper function to get icon component name
export function getHandIconComponent(iconName: string): string {
const iconMap: Record<string, string> = {
Video: 'Video',
UserPlus: 'UserPlus',
Database: 'Database',
TrendingUp: 'TrendingUp',
Search: 'Search',
Twitter: 'Twitter',
Globe: 'Globe',
};
return iconMap[iconName] || 'Box';
}