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,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>
);
}