cc工作前备份

This commit is contained in:
iven
2026-03-12 00:23:42 +08:00
parent f75a2b798b
commit ef849c62ab
98 changed files with 12110 additions and 568 deletions

View File

@@ -1,13 +1,34 @@
import './index.css';
import { useState, useEffect } from 'react';
import './index.css';
import { Sidebar } from './components/Sidebar';
import { ChatArea } from './components/ChatArea';
import { RightPanel } from './components/RightPanel';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { useGatewayStore } from './store/gatewayStore';
type View = 'main' | 'settings';
function App() {
const [view, setView] = useState<View>('main');
const { connect, connectionState } = useGatewayStore();
// Auto-connect to Gateway on startup
useEffect(() => {
if (connectionState === 'disconnected') {
connect().catch(() => {
// Silent fail — user can manually connect via Settings
});
}
}, []);
if (view === 'settings') {
return <SettingsLayout onBack={() => setView('main')} />;
}
return (
<div className="h-screen flex overflow-hidden text-gray-800 text-sm">
{/* 左侧边栏 */}
<Sidebar />
<Sidebar onOpenSettings={() => setView('settings')} />
{/* 中间对话区域 */}
<main className="flex-1 flex flex-col bg-white relative">

View File

@@ -0,0 +1,120 @@
import { useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { Radio, RefreshCw, MessageCircle, Settings } from 'lucide-react';
const CHANNEL_ICONS: Record<string, string> = {
feishu: '飞',
qqbot: 'QQ',
wechat: '微',
};
interface ChannelListProps {
onOpenSettings?: () => void;
}
export function ChannelList({ onOpenSettings }: ChannelListProps) {
const { channels, connectionState, loadChannels, loadPluginStatus } = useGatewayStore();
const connected = connectionState === 'connected';
useEffect(() => {
if (connected) {
loadPluginStatus().then(() => loadChannels());
}
}, [connected]);
const handleRefresh = () => {
loadPluginStatus().then(() => loadChannels());
};
if (!connected) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
<Radio className="w-8 h-8 mb-2 opacity-30" />
<p>IM </p>
<p className="mt-1"> Gateway </p>
</div>
);
}
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={handleRefresh}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="刷新"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Configured channels */}
{channels.map((ch) => (
<div
key={ch.id}
className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50"
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 ${
ch.status === 'active'
? 'bg-gradient-to-br from-blue-500 to-indigo-500'
: 'bg-gray-300'
}`}>
{CHANNEL_ICONS[ch.type] || <MessageCircle className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 truncate">{ch.label}</div>
<div className={`text-[11px] ${
ch.status === 'active' ? 'text-green-500' : ch.status === 'error' ? 'text-red-500' : 'text-gray-400'
}`}>
{ch.status === 'active' ? '已连接' : ch.status === 'error' ? ch.error || '错误' : '未配置'}
{ch.accounts !== undefined && ch.accounts > 0 && ` · ${ch.accounts} 个账号`}
</div>
</div>
</div>
))}
{/* Always show available channels that aren't configured */}
{!channels.find(c => c.type === 'feishu') && (
<div className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-600"> (Feishu)</div>
<div className="text-[11px] text-gray-400"></div>
</div>
</div>
)}
{!channels.find(c => c.type === 'qqbot') && (
<div className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
QQ
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-600">QQ </div>
<div className="text-[11px] text-gray-400"></div>
</div>
</div>
)}
{/* Help text */}
<div className="px-3 py-4 text-center">
<p className="text-[11px] text-gray-400 mb-2"> IM </p>
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="inline-flex items-center gap-1 text-xs text-orange-500 hover:text-orange-600"
>
<Settings className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,102 +1,151 @@
import { useState } from 'react';
import { useChatStore } from '../store/chatStore';
import { Send, Paperclip, ChevronDown } from 'lucide-react';
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';
const MODELS = ['glm-5', 'qwen3.5-plus', 'kimi-k2.5', 'minimax-m2.5'];
export function ChatArea() {
const { messages, currentAgent, addMessage } = useChatStore();
const {
messages, currentAgent, isStreaming, currentModel,
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation,
} = useChatStore();
const { connectionState } = useGatewayStore();
const [input, setInput] = useState('');
const [showModelPicker, setShowModelPicker] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sendMessage = () => {
if (!input.trim()) return;
// Auto-resize textarea
const adjustTextarea = useCallback(() => {
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
}, []);
const userMessage = {
id: Date.now().toString(),
role: 'user' as const,
content: input,
timestamp: new Date(),
};
// Init agent stream listener on mount
useEffect(() => {
const unsub = initStreamListener();
return unsub;
}, []);
addMessage(userMessage);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSend = () => {
if (!input.trim() || isStreaming) return;
sendToGateway(input);
setInput('');
// TODO: 调用后端 API
setTimeout(() => {
const aiMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant' as const,
content: '收到你的消息了!正在处理中...',
timestamp: new Date(),
};
addMessage(aiMessage);
}, 1000);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const connected = connectionState === 'connected';
return (
<>
{/* 顶部标题栏 */}
{/* Header */}
<div className="h-14 border-b border-gray-100 flex items-center justify-between px-6 flex-shrink-0">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-gray-900">{currentAgent?.name || 'ZCLAW'}</h2>
<span className="text-xs text-gray-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full"></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 && (
<button
onClick={newConversation}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 hover:text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
title="新对话"
>
<SquarePen className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 聊天内容区 */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 py-20">
<p className="text-lg mb-2">使 ZCLAW 🦞</p>
<p className="text-sm"></p>
<p className="text-sm">{connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={ lex gap-4 }
>
<div
className={w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 }
>
{message.role === 'user' ? '用' : '🦞'}
</div>
<div className={message.role === 'user' ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
<div className={message.role === 'user' ? 'chat-bubble-user p-4 shadow-md' : 'chat-bubble-assistant p-4 shadow-sm'}>
<p className="leading-relaxed text-gray-700">{message.content}</p>
</div>
</div>
</div>
<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 */}
<div className="border-t border-gray-100 p-4 bg-white">
<div className="max-w-4xl mx-auto">
<div className="relative flex items-end gap-2 bg-gray-50 rounded-2xl border border-gray-200 p-2 focus-within:border-orange-300 focus-within:ring-2 focus-within:ring-orange-100 transition-all">
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg">
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 py-2">
<input
type="text"
<div className="flex-1 py-1">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="发送给 ZCLAW"
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400"
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
onKeyDown={handleKeyDown}
placeholder={isStreaming ? 'Agent 正在回复...' : '发送给 ZCLAWShift+Enter 换行)'}
disabled={isStreaming}
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"
style={{ minHeight: '24px', maxHeight: '160px' }}
/>
</div>
<div className="flex items-center gap-2 pr-2 pb-1">
<button className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 rounded-md transition-colors">
<span>glm5</span>
<div className="flex items-center gap-2 pr-2 pb-1 relative">
<button
onClick={() => setShowModelPicker(!showModelPicker)}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 rounded-md transition-colors"
>
<span>{currentModel}</span>
<ChevronDown className="w-3 h-3" />
</button>
{showModelPicker && (
<div className="absolute bottom-full right-8 mb-2 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[160px] z-10">
{MODELS.map((model) => (
<button
key={model}
onClick={() => { setCurrentModel(model); setShowModelPicker(false); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 ${model === currentModel ? 'text-orange-600 font-medium' : 'text-gray-700'}`}
>
{model}
</button>
))}
</div>
)}
<button
onClick={sendMessage}
className="w-8 h-8 bg-gray-900 text-white rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors"
onClick={handleSend}
disabled={isStreaming || !input.trim()}
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" />
</button>
@@ -110,3 +159,132 @@ export function ChatArea() {
</>
);
}
/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
function renderMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
const lines = text.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
nodes.push(
<pre key={nodes.length} className="bg-gray-900 text-gray-100 rounded-lg p-3 my-2 overflow-x-auto text-xs font-mono leading-relaxed">
{lang && <div className="text-gray-500 text-[10px] mb-1 uppercase">{lang}</div>}
<code>{codeLines.join('\n')}</code>
</pre>
);
continue;
}
// Normal line — parse inline markdown
nodes.push(
<span key={nodes.length}>
{i > 0 && lines[i - 1] !== undefined && !nodes[nodes.length - 1]?.toString().includes('pre') && '\n'}
{renderInline(line)}
</span>
);
i++;
}
return nodes;
}
function renderInline(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
// Pattern: **bold**, *italic*, `code`, [text](url)
const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|(\[(.+?)\]\((.+?)\))/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Text before match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[1]) {
// **bold**
parts.push(<strong key={parts.length} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) {
// *italic*
parts.push(<em key={parts.length}>{match[4]}</em>);
} else if (match[5]) {
// `code`
parts.push(
<code key={parts.length} className="bg-gray-100 text-orange-700 px-1 py-0.5 rounded text-[0.85em] font-mono">
{match[6]}
</code>
);
} else if (match[7]) {
// [text](url)
parts.push(
<a key={parts.length} href={match[9]} target="_blank" rel="noopener noreferrer"
className="text-orange-600 underline hover:text-orange-700">{match[8]}</a>
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
}
function MessageBubble({ message }: { message: Message }) {
if (message.role === 'tool') {
return (
<div className="ml-12 bg-gray-50 border border-gray-200 rounded-lg p-3 text-xs font-mono">
<div className="flex items-center gap-2 text-gray-500 mb-1">
<Terminal className="w-3.5 h-3.5" />
<span className="font-semibold">{message.toolName || 'tool'}</span>
</div>
{message.toolInput && (
<pre className="text-gray-600 bg-white rounded p-2 mb-1 overflow-x-auto">{message.toolInput}</pre>
)}
{message.content && (
<pre className="text-green-700 bg-white rounded p-2 overflow-x-auto">{message.content}</pre>
)}
</div>
);
}
const isUser = message.role === 'user';
return (
<div className={`flex gap-4 ${isUser ? 'justify-end' : ''}`}>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${isUser ? 'bg-gray-200 text-gray-600 order-last' : 'agent-avatar text-white'}`}
>
{isUser ? '用' : '🦞'}
</div>
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'}`}>
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700'}`}>
{message.content
? (isUser ? message.content : renderMarkdown(message.content))
: (message.streaming ? '' : '...')}
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
</div>
{message.error && (
<p className="text-xs text-red-500 mt-2">{message.error}</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { useChatStore } from '../store/chatStore';
import { Plus, Trash2, Bot, X } from 'lucide-react';
interface CloneFormData {
name: string;
role: string;
scenarios: string;
}
export function CloneManager() {
const { clones, loadClones, createClone, deleteClone, connectionState } = useGatewayStore();
const { agents } = useChatStore();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<CloneFormData>({ name: '', role: '', scenarios: '' });
const connected = connectionState === 'connected';
useEffect(() => {
if (connected) {
loadClones();
}
}, [connected]);
const handleCreate = async () => {
if (!form.name.trim()) return;
await createClone({
name: form.name,
role: form.role || undefined,
scenarios: form.scenarios ? form.scenarios.split(',').map(s => s.trim()) : undefined,
});
setForm({ name: '', role: '', scenarios: '' });
setShowForm(false);
};
const handleDelete = async (id: string) => {
if (confirm('确定删除该分身?')) {
await deleteClone(id);
}
};
// Merge gateway clones with local agents for display
const displayClones = clones.length > 0 ? clones : agents.map(a => ({
id: a.id,
name: a.name,
role: '默认助手',
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>
{/* Create form */}
{showForm && (
<div className="p-3 border-b border-gray-200 bg-orange-50 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-orange-700"></span>
<button onClick={() => setShowForm(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
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"
/>
<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"
/>
<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"
/>
<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"
>
</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.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>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useChatStore } from '../store/chatStore';
import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
export function ConversationList() {
const {
conversations, currentConversationId, messages,
newConversation, switchConversation, deleteConversation,
} = useChatStore();
const hasActiveChat = messages.length > 0;
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={newConversation}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="新对话"
>
<SquarePen className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Current active chat (unsaved) */}
{hasActiveChat && !currentConversationId && (
<div className="flex items-center gap-3 px-3 py-3 bg-orange-50 border-b border-orange-100 cursor-default">
<div className="w-7 h-7 bg-orange-500 rounded-lg flex items-center justify-center text-white flex-shrink-0">
<MessageSquare className="w-3.5 h-3.5" />
</div>
<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}
</div>
</div>
</div>
)}
{/* Saved conversations */}
{conversations.map((conv) => {
const isActive = conv.id === currentConversationId;
const msgCount = conv.messages.filter(m => m.role === 'user').length;
const timeStr = formatTime(conv.updatedAt);
return (
<div
key={conv.id}
onClick={() => switchConversation(conv.id)}
className={`group flex items-center gap-3 px-3 py-3 cursor-pointer border-b border-gray-50 transition-colors ${
isActive ? 'bg-orange-50' : 'hover:bg-gray-100'
}`}
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 ${
isActive ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-500'
}`}>
<MessageSquare className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium truncate ${isActive ? 'text-orange-700' : 'text-gray-900'}`}>
{conv.title}
</div>
<div className="text-[11px] text-gray-400 truncate">
{msgCount} · {timeStr}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('删除该对话?')) {
deleteConversation(conv.id);
}
}}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-300 hover:text-red-500 transition-opacity"
title="删除"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
{conversations.length === 0 && !hasActiveChat && (
<div className="text-center py-8 text-xs text-gray-400">
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p></p>
<p className="mt-1"></p>
</div>
)}
</div>
</div>
);
}
function formatTime(date: Date): string {
const now = new Date();
const d = new Date(date);
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return '刚刚';
if (diffMin < 60) return `${diffMin} 分钟前`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr} 小时前`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay} 天前`;
return `${d.getMonth() + 1}/${d.getDate()}`;
}

View File

@@ -1,103 +1,214 @@
import { FileText, User, Target, CheckSquare } from 'lucide-react';
import { useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { useChatStore } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, Activity,
} from 'lucide-react';
export function RightPanel() {
const {
connectionState, gatewayVersion, error, clones, usageStats, pluginStatus,
connect, loadClones, loadUsageStats, loadPluginStatus,
} = useGatewayStore();
const { messages, currentModel } = useChatStore();
const connected = connectionState === 'connected';
// Load data when connected
useEffect(() => {
if (connected) {
loadClones();
loadUsageStats();
loadPluginStatus();
}
}, [connected]);
const handleReconnect = () => {
connect().catch(() => {});
};
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;
return (
<aside className="w-80 bg-white border-l border-gray-200 flex flex-col flex-shrink-0">
{/* 顶部工具栏 */}
<aside className="w-72 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-3">
<div className="flex items-center gap-1 text-gray-600">
<Target className="w-4 h-4" />
<span className="font-medium">2268</span>
</div>
<button className="text-xs text-orange-600 hover:underline"></button>
<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>
<div className="flex items-center gap-3 text-gray-500">
<button className="hover:text-gray-700 flex items-center gap-1 text-xs">
<FileText className="w-4 h-4" />
<span></span>
{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>
<button className="hover:text-gray-700 flex items-center gap-1 text-xs">
<User className="w-4 h-4" />
<span>Agent</span>
</button>
</div>
)}
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
{/* 任务进度 */}
<div className="bg-gray-50 rounded-lg border border-gray-100 mb-6 overflow-hidden">
<div className="p-3">
<div className="flex justify-between text-xs mb-2">
<span className="text-gray-600"></span>
<span className="font-medium text-gray-900">65%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-orange-500 h-2 rounded-full transition-all" style={{ width: '65%' }}></div>
</div>
</div>
<div className="border-t border-gray-100 divide-y divide-gray-100 text-xs">
<div className="py-2 px-3 flex justify-between">
<span className="text-gray-600"></span>
<span className="text-green-600"> </span>
</div>
<div className="py-2 px-3 flex justify-between">
<span className="text-gray-600"></span>
<span className="text-orange-600">🔄 </span>
</div>
<div className="py-2 px-3 flex justify-between">
<span className="text-gray-600"> Agent </span>
<span className="text-gray-400"> </span>
</div>
</div>
</div>
{/* 今日统计 */}
<div className="mb-6">
<h3 className="font-bold text-gray-900 mb-3 text-sm"></h3>
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium text-gray-900">8 </span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium text-green-600">6 </span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium text-orange-600">2 </span>
</div>
</div>
</div>
</div>
{/* 下一步行动 */}
<div>
<h3 className="font-bold text-gray-900 mb-3 text-sm flex items-center gap-2">
<span className="w-5 h-5 bg-orange-500 rounded-full flex items-center justify-center text-white text-xs">
<Target className="w-3 h-3" />
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
{/* 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" />
)}
<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>
</div>
{gatewayVersion && (
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">{gatewayVersion}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-orange-600 font-medium">{currentModel}</span>
</div>
</div>
{!connected && connectionState !== 'connecting' && (
<button
onClick={handleReconnect}
className="mt-2 w-full text-xs bg-orange-500 text-white rounded py-1.5 hover:bg-orange-600 transition-colors"
>
Gateway
</button>
)}
{error && (
<p className="mt-2 text-xs text-red-500 truncate" title={error}>{error}</p>
)}
</div>
{/* 当前会话 */}
<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">
<MessageSquare className="w-3.5 h-3.5" />
</h3>
<div className="ml-7 space-y-2">
<h4 className="text-xs font-semibold text-gray-900 mb-2"> ()</h4>
<ul className="space-y-2">
<li className="flex items-start gap-2 text-xs text-gray-600">
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
<span></span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-600">
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
<span></span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-600">
<div className="w-3 h-3 border border-gray-300 rounded-sm mt-0.5 flex-shrink-0"></div>
<span> OpenClaw SDK</span>
</li>
</ul>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{userMsgCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{assistantMsgCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{toolCallCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-orange-600">{messages.length}</span>
</div>
</div>
</div>
{/* 分身 */}
<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">
{clones.slice(0, 5).map(c => (
<div key={c.id} className="flex items-center gap-2 text-xs">
<div className="w-5 h-5 bg-gradient-to-br from-orange-400 to-red-500 rounded-md flex items-center justify-center text-white text-[10px]">
<Bot className="w-3 h-3" />
</div>
<span className="text-gray-700 truncate">{c.name}</span>
</div>
))}
{clones.length > 5 && (
<p className="text-xs text-gray-400">+{clones.length - 5} </p>
)}
</div>
) : (
<p className="text-xs text-gray-400">
{connected ? '暂无分身,在左侧栏创建' : '连接后可用'}
</p>
)}
</div>
{/* 用量统计 */}
{usageStats && (
<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">
<BarChart3 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"></span>
<span className="font-medium text-gray-900">{usageStats.totalSessions}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{usageStats.totalMessages}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"> Token</span>
<span className="font-medium text-gray-900">{usageStats.totalTokens.toLocaleString()}</span>
</div>
</div>
</div>
)}
{/* 插件状态 */}
{pluginStatus.length > 0 && (
<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">
<Plug className="w-3.5 h-3.5" />
({pluginStatus.length})
</h3>
<div className="space-y-1 text-xs">
{pluginStatus.map((p: any, i: number) => (
<div key={i} className="flex justify-between">
<span className="text-gray-600 truncate">{p.name || p.id}</span>
<span className={p.status === 'active' ? 'text-green-600' : 'text-gray-400'}>
{p.status === 'active' ? '运行中' : '已停止'}
</span>
</div>
))}
</div>
</div>
)}
{/* 系统信息 */}
<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>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">Gateway v3</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">Tauri 2.0</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,45 @@
export function About() {
return (
<div>
<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>
<div>
<h1 className="text-2xl font-bold text-gray-900">ZCLAW</h1>
<p className="text-sm text-orange-500"> 0.2.0</p>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-5 mb-4">
<div className="flex justify-between items-center">
<div>
<h2 className="text-sm font-bold text-gray-900"></h2>
</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>
</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>
<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>
<a href="#" className="text-orange-500 hover:text-orange-600"></a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
export function General() {
const { connectionState, gatewayVersion, error, connect, disconnect } = useGatewayStore();
const { currentModel } = useChatStore();
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [autoStart, setAutoStart] = useState(false);
const [showToolCalls, setShowToolCalls] = useState(false);
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
const handleConnect = () => { connect().catch(() => {}); };
const handleDisconnect = () => { disconnect(); };
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8"></h1>
<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 mb-6 space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400' : connecting ? 'bg-yellow-400 animate-pulse' : 'bg-gray-300'}`} />
<span className={`text-sm font-medium ${connected ? 'text-green-600' : connecting ? 'text-yellow-600' : 'text-gray-400'}`}>
{connected ? '已连接' : connecting ? '连接中...' : connectionState === 'handshaking' ? '握手中...' : '未连接'}
</span>
</div>
</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>
</div>
{gatewayVersion && (
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-gray-500">{gatewayVersion}</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<span className="text-sm text-orange-600 font-medium">{currentModel}</span>
</div>
{error && (
<div className="text-xs text-red-500 bg-red-50 rounded-lg p-2">{error}</div>
)}
<div className="flex gap-2 pt-1">
{connected ? (
<button
onClick={handleDisconnect}
className="text-sm border border-gray-300 rounded-lg px-4 py-1.5 hover:bg-gray-100 text-gray-600"
>
</button>
) : (
<button
onClick={handleConnect}
disabled={connecting}
className="text-sm bg-orange-500 text-white rounded-lg px-4 py-1.5 hover:bg-orange-600 disabled:opacity-50"
>
{connecting ? '连接中...' : '连接 Gateway'}
</button>
)}
</div>
</div>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3"></h2>
<div className="bg-gray-50 rounded-xl p-5 space-y-5">
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500 mt-0.5"></div>
</div>
<div className="flex gap-2">
<button
onClick={() => setTheme('light')}
className={`w-8 h-8 rounded-full border-2 ${theme === 'light' ? 'border-orange-500' : 'border-gray-300'} bg-white`}
/>
<button
onClick={() => setTheme('dark')}
className={`w-8 h-8 rounded-full border-2 ${theme === 'dark' ? 'border-orange-500' : 'border-gray-300'} bg-gray-900`}
/>
</div>
</div>
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500 mt-0.5"> ZCLAW</div>
</div>
<Toggle checked={autoStart} onChange={setAutoStart} />
</div>
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-gray-900"></div>
<div className="text-xs text-gray-500 mt-0.5"></div>
</div>
<Toggle checked={showToolCalls} onChange={setShowToolCalls} />
</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 ${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>
);
}

View File

@@ -0,0 +1,34 @@
import { Plus, RefreshCw } from 'lucide-react';
export function IMChannels() {
return (
<div>
<div className="flex justify-between items-center mb-2">
<h1 className="text-2xl 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" />
</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>
<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>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import { Plus, RefreshCw } from 'lucide-react';
interface MCPService {
id: string;
name: string;
enabled: boolean;
}
export function MCPServices() {
const [services, setServices] = useState<MCPService[]>([
{ id: 'filesystem', name: 'File System', enabled: true },
{ id: 'webfetch', name: 'Web Fetch', enabled: true },
]);
const toggleService = (id: string) => {
setServices(prev => prev.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
};
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>
<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="flex items-center gap-3">
<span className="text-gray-400"></span>
<span className="text-sm text-gray-900">{svc.name}</span>
</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">
{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>
<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>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
interface ModelEntry {
id: string;
name: string;
provider: string;
}
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: 'minimax-m2.5', name: 'MiniMax-M2.5', provider: 'MiniMax' },
];
export function ModelsAPI() {
const { connectionState, connect, disconnect } = useGatewayStore();
const { currentModel, setCurrentModel } = useChatStore();
const [gatewayUrl, setGatewayUrl] = useState('ws://127.0.0.1:18789');
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
const handleReconnect = () => {
disconnect();
setTimeout(() => connect().catch(() => {}), 500);
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl 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"
>
{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>
<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'}`}>
{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>
<p className="text-xs text-gray-400">
默认地址: ws://127.0.0.1:18789。修改后需重新连接。
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useState } from 'react';
export function Privacy() {
const [optimization, setOptimization] = useState(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="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>
</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>
</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>
<div className="flex gap-8">
<span className="text-gray-400 w-24"></span>
<span>MIT License</span>
</div>
<div className="flex gap-8">
<span className="text-gray-400 w-24"></span>
<span></span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import {
Settings as SettingsIcon,
BarChart3,
Bot,
Puzzle,
Blocks,
MessageSquare,
FolderOpen,
Shield,
MessageCircle,
Info,
ArrowLeft,
} from 'lucide-react';
import { General } from './General';
import { UsageStats } from './UsageStats';
import { ModelsAPI } from './ModelsAPI';
import { MCPServices } from './MCPServices';
import { Skills } from './Skills';
import { IMChannels } from './IMChannels';
import { Workspace } from './Workspace';
import { Privacy } from './Privacy';
import { About } from './About';
interface SettingsLayoutProps {
onBack: () => void;
}
type SettingsPage =
| 'general'
| 'usage'
| 'models'
| 'mcp'
| 'skills'
| 'im'
| 'workspace'
| 'privacy'
| '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: 'mcp', label: 'MCP 服务', icon: <Puzzle className="w-4 h-4" /> },
{ id: 'skills', label: '技能', icon: <Blocks 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: 'about', label: '关于', icon: <Info className="w-4 h-4" /> },
];
export function SettingsLayout({ onBack }: SettingsLayoutProps) {
const [activePage, setActivePage] = useState<SettingsPage>('general');
const renderPage = () => {
switch (activePage) {
case 'general': return <General />;
case 'usage': return <UsageStats />;
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 'feedback': return <Feedback />;
case 'about': return <About />;
default: return <General />;
}
};
return (
<div className="h-screen flex bg-white">
{/* 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>
<nav className="flex-1 py-2">
{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 ${
activePage === item.id
? 'bg-orange-50 text-orange-600 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{item.icon}
{item.label}
</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>
</div>
);
}
// Simple feedback page (inline)
function Feedback() {
const [text, setText] = useState('');
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>
);
}

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
export function Skills() {
const [extraDir, setExtraDir] = useState('~/.opencode/skills');
const [activeTab, setActiveTab] = useState<'all' | 'available' | 'installed'>('all');
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>
<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>
<div className="flex gap-2">
<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"
/>
<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'
}`}
>
{tab === 'all' ? '全部 (0)' : tab === 'available' ? '可用 (0)' : '已安装 (0)'}
</button>
))}
</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>
);
}

View File

@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
export function UsageStats() {
const { usageStats, loadUsageStats, connectionState } = useGatewayStore();
useEffect(() => {
if (connectionState === 'connected') {
loadUsageStats();
}
}, [connectionState]);
const stats = usageStats || { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} };
const models = Object.entries(stats.byModel || {});
const formatTokens = (n: number) => {
if (n >= 1_000_000) return `~${(n / 1_000_000).toFixed(1)} M`;
if (n >= 1_000) return `~${(n / 1_000).toFixed(1)} k`;
return `${n}`;
};
return (
<div>
<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">
</button>
</div>
<p className="text-sm text-gray-500 mb-6"> Token </p>
<div className="grid grid-cols-3 gap-4 mb-8">
<StatCard label="会话数" value={stats.totalSessions} />
<StatCard label="消息数" value={stats.totalMessages} />
<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">
{models.length === 0 && (
<p className="text-sm text-gray-400 text-center py-4"></p>
)}
{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;
return (
<div key={model}>
<div className="flex justify-between items-center mb-1">
<span className="text-sm 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>
<div className="flex justify-between text-xs text-gray-400">
<span>: {formatTokens(data.inputTokens)}</span>
<span>: {formatTokens(data.outputTokens)}</span>
<span>: {formatTokens(total)}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
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>
);
}

View File

@@ -0,0 +1,82 @@
import { useState } from 'react';
export function Workspace() {
const [projectDir, setProjectDir] = useState('~/.openclaw/zclaw-workspace');
const [restrictFiles, setRestrictFiles] = useState(true);
const [autoSaveContext, setAutoSaveContext] = useState(true);
const [fileWatching, setFileWatching] = useState(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="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="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"
/>
<button className="border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-100"></button>
</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>
<button className="border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-100"></button>
</div>
</div>
</div>
);
}
function ToggleCard({ title, description, checked, onChange, highlight }: {
title: string; description: string; checked: boolean; onChange: (v: boolean) => void; highlight?: boolean;
}) {
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>
);
}

View File

@@ -1,56 +1,55 @@
import { useChatStore } from '../store/chatStore';
import { Settings, Cat, Search, Globe, BarChart } from 'lucide-react';
import { useState } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { Settings, MessageSquare, Clock, Bot, Radio } from 'lucide-react';
import { CloneManager } from './CloneManager';
import { ConversationList } from './ConversationList';
import { ChannelList } from './ChannelList';
import { TaskList } from './TaskList';
export function Sidebar() {
const { agents, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = React.useState('agents');
interface SidebarProps {
onOpenSettings?: () => void;
}
type Tab = 'chats' | 'clones' | 'channels' | 'tasks';
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 },
];
export function Sidebar({ onOpenSettings }: SidebarProps) {
const { connectionState } = useGatewayStore();
const [activeTab, setActiveTab] = useState<Tab>('chats');
const connected = connectionState === 'connected';
return (
<aside className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
{/* 顶部标签 */}
<div className="flex border-b border-gray-200 bg-white">
<button
className={ lex-1 py-3 px-4 text-xs font-medium }
onClick={() => setActiveTab('agents')}
>
</button>
<button
className={ lex-1 py-3 px-4 text-xs font-medium }
onClick={() => setActiveTab('channels')}
>
IM
</button>
<button
className={ lex-1 py-3 px-4 text-xs font-medium }
onClick={() => setActiveTab('tasks')}
>
</button>
{TABS.map(({ key, label }) => (
<button
key={key}
className={`flex-1 py-3 px-2 text-xs font-medium transition-colors ${
activeTab === key
? 'text-orange-600 border-b-2 border-orange-500'
: 'text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab(key)}
>
{label}
</button>
))}
</div>
{/* Agent 列表 */}
<div className="flex-1 overflow-y-auto custom-scrollbar py-2">
{agents.map((agent) => (
<div
key={agent.id}
className={sidebar-item mx-2 px-3 py-3 rounded-lg cursor-pointer mb-1 }
onClick={() => setCurrentAgent(agent)}
>
<div className="flex items-start gap-3">
<div className={w-10 h-10 rounded-xl flex items-center justify-center text-white flex-shrink-0}>
<span className="text-xl">{agent.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-0.5">
<span className="font-semibold text-gray-900 truncate">{agent.name}</span>
<span className="text-xs text-gray-400">{agent.time}</span>
</div>
<p className="text-xs text-gray-500 truncate leading-relaxed">{agent.lastMessage}</p>
</div>
</div>
</div>
))}
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{activeTab === 'chats' && <ConversationList />}
{activeTab === 'clones' && <CloneManager />}
{activeTab === 'channels' && <ChannelList onOpenSettings={onOpenSettings} />}
{activeTab === 'tasks' && <TaskList />}
</div>
{/* 底部用户 */}
@@ -59,8 +58,13 @@ export function Sidebar() {
<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>
<span className="font-medium text-gray-700">7141</span>
<button className="ml-auto text-gray-400 hover:text-gray-600">
<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}>
<Settings className="w-4 h-4" />
</button>
</div>

View File

@@ -0,0 +1,107 @@
import { useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { Clock, RefreshCw, Play, Pause, AlertCircle, CheckCircle2 } from 'lucide-react';
const STATUS_CONFIG: Record<string, { icon: typeof Play; color: string; label: string }> = {
active: { icon: Play, color: 'text-green-500', label: '运行中' },
paused: { icon: Pause, color: 'text-yellow-500', label: '已暂停' },
completed: { icon: CheckCircle2, color: 'text-blue-500', label: '已完成' },
error: { icon: AlertCircle, color: 'text-red-500', label: '错误' },
};
export function TaskList() {
const { scheduledTasks, connectionState, loadScheduledTasks } = useGatewayStore();
const connected = connectionState === 'connected';
useEffect(() => {
if (connected) {
loadScheduledTasks();
}
}, [connected]);
if (!connected) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
<Clock className="w-8 h-8 mb-2 opacity-30" />
<p></p>
<p className="mt-1"> Gateway </p>
</div>
);
}
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">Heartbeat </span>
<button
onClick={loadScheduledTasks}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="刷新"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{scheduledTasks.length > 0 ? (
scheduledTasks.map((task) => {
const cfg = STATUS_CONFIG[task.status] || STATUS_CONFIG.active;
const StatusIcon = cfg.icon;
return (
<div
key={task.id}
className="px-3 py-3 border-b border-gray-50 hover:bg-gray-50"
>
<div className="flex items-center gap-2 mb-1">
<StatusIcon className={`w-3.5 h-3.5 flex-shrink-0 ${cfg.color}`} />
<span className="text-xs font-medium text-gray-900 truncate">{task.name}</span>
</div>
<div className="pl-5.5 space-y-0.5">
<div className="text-[11px] text-gray-500 font-mono">{task.schedule}</div>
{task.description && (
<div className="text-[11px] text-gray-400 truncate">{task.description}</div>
)}
<div className="flex gap-3 text-[10px] text-gray-400">
{task.lastRun && <span>: {formatTaskTime(task.lastRun)}</span>}
{task.nextRun && <span>: {formatTaskTime(task.nextRun)}</span>}
</div>
</div>
</div>
);
})
) : (
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
<Clock className="w-8 h-8 mb-2 opacity-30" />
<p></p>
<p className="mt-1">Heartbeat </p>
<p className="mt-0.5 text-[11px]">默认心跳周期: 1h</p>
</div>
)}
</div>
</div>
);
}
function formatTaskTime(timeStr: string): string {
try {
const d = new Date(timeStr);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const future = diffMs < 0;
const absDiff = Math.abs(diffMs);
const mins = Math.floor(absDiff / 60000);
if (mins < 1) return future ? '即将' : '刚刚';
if (mins < 60) return future ? `${mins}分钟后` : `${mins}分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return future ? `${hrs}小时后` : `${hrs}小时前`;
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
} catch {
return timeStr;
}
}

View File

@@ -1,8 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
@import "tailwindcss";
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

View File

@@ -0,0 +1,408 @@
/**
* ZCLAW Gateway Client (Browser/Tauri side)
*
* WebSocket client for OpenClaw Gateway protocol, designed to run
* in the Tauri React frontend. Uses native browser WebSocket API.
*/
// === Protocol Types ===
export interface GatewayRequest {
type: 'req';
id: string;
method: string;
params?: Record<string, any>;
}
export interface GatewayResponse {
type: 'res';
id: string;
ok: boolean;
payload?: any;
error?: any;
}
export interface GatewayEvent {
type: 'event';
event: string;
payload?: any;
seq?: number;
}
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent;
export interface AgentStreamDelta {
stream: 'assistant' | 'tool' | 'lifecycle';
delta?: string;
content?: string;
tool?: string;
toolInput?: string;
toolOutput?: string;
phase?: 'start' | 'end' | 'error';
runId?: string;
error?: string;
}
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
type EventCallback = (payload: any) => void;
// === Client ===
export class GatewayClient {
private ws: WebSocket | null = null;
private state: ConnectionState = 'disconnected';
private requestId = 0;
private pendingRequests = new Map<string, {
resolve: (value: any) => void;
reject: (reason: any) => void;
timer: number;
}>();
private eventListeners = new Map<string, Set<EventCallback>>();
private reconnectAttempts = 0;
private reconnectTimer: number | null = null;
private deviceId: string;
// Options
private url: string;
private token: string;
private autoReconnect: boolean;
private reconnectInterval: number;
private requestTimeout: number;
// State change callbacks
onStateChange?: (state: ConnectionState) => void;
onLog?: (level: string, message: string) => void;
constructor(opts?: {
url?: string;
token?: string;
autoReconnect?: boolean;
reconnectInterval?: number;
requestTimeout?: number;
}) {
this.url = opts?.url || 'ws://127.0.0.1:18789';
this.token = opts?.token || '';
this.autoReconnect = opts?.autoReconnect ?? true;
this.reconnectInterval = opts?.reconnectInterval || 3000;
this.requestTimeout = opts?.requestTimeout || 30000;
this.deviceId = crypto.randomUUID?.() || `zclaw_${Date.now().toString(36)}`;
}
getState(): ConnectionState {
return this.state;
}
// === Connection ===
connect(): Promise<void> {
if (this.state === 'connected' || this.state === 'connecting') {
return Promise.resolve();
}
this.setState('connecting');
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.setState('handshaking');
};
this.ws.onmessage = (evt) => {
try {
const frame: GatewayFrame = JSON.parse(evt.data);
this.handleFrame(frame, resolve);
} catch (err: any) {
this.log('error', `Parse error: ${err.message}`);
}
};
this.ws.onclose = (evt) => {
const wasConnected = this.state === 'connected';
this.cleanup();
if (wasConnected && this.autoReconnect) {
this.scheduleReconnect();
}
this.emitEvent('close', { code: evt.code, reason: evt.reason });
};
this.ws.onerror = () => {
if (this.state === 'connecting') {
this.cleanup();
reject(new Error('WebSocket connection failed'));
}
};
} catch (err) {
this.cleanup();
reject(err);
}
});
}
disconnect() {
this.autoReconnect = false;
this.cancelReconnect();
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
}
this.cleanup();
}
// === Request/Response ===
async request(method: string, params?: Record<string, any>): Promise<any> {
if (this.state !== 'connected') {
throw new Error(`Not connected (state: ${this.state})`);
}
const id = `req_${++this.requestId}`;
const frame: GatewayRequest = { type: 'req', id, method, params };
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, this.requestTimeout);
this.pendingRequests.set(id, { resolve, reject, timer });
this.send(frame);
});
}
// === High-level API ===
/** Send message to agent, returns { runId, acceptedAt } */
async chat(message: string, opts?: { sessionKey?: string; model?: string }): Promise<{ runId: string; acceptedAt: string }> {
return this.request('agent', {
message,
sessionKey: opts?.sessionKey,
model: opts?.model,
});
}
/** Get Gateway health info */
async health(): Promise<any> {
return this.request('health');
}
/** Get Gateway status */
async status(): Promise<any> {
return this.request('status');
}
// ZCLAW custom methods
async listClones(): Promise<any> { return this.request('zclaw.clones.list'); }
async createClone(opts: { name: string; role?: string; scenarios?: string[] }): Promise<any> { return this.request('zclaw.clones.create', opts); }
async updateClone(id: string, updates: Record<string, any>): Promise<any> { return this.request('zclaw.clones.update', { id, updates }); }
async deleteClone(id: string): Promise<any> { return this.request('zclaw.clones.delete', { id }); }
async getUsageStats(): Promise<any> { return this.request('zclaw.stats.usage'); }
async getSessionStats(): Promise<any> { return this.request('zclaw.stats.sessions'); }
async getWorkspaceInfo(): Promise<any> { return this.request('zclaw.workspace.info'); }
async getPluginStatus(): Promise<any> { return this.request('zclaw.plugins.status'); }
async getQuickConfig(): Promise<any> { return this.request('zclaw.config.quick', { get: true }); }
async saveQuickConfig(config: Record<string, any>): Promise<any> { return this.request('zclaw.config.quick', config); }
async listChannels(): Promise<any> { return this.request('channels.list'); }
async getFeishuStatus(): Promise<any> { return this.request('feishu.status'); }
async listScheduledTasks(): Promise<any> { return this.request('heartbeat.tasks'); }
// === Event Subscription ===
/** Subscribe to a Gateway event (e.g., 'agent', 'chat', 'heartbeat') */
on(event: string, callback: EventCallback): () => void {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
this.eventListeners.get(event)!.add(callback);
// Return unsubscribe function
return () => {
this.eventListeners.get(event)?.delete(callback);
};
}
/** Subscribe to agent stream events */
onAgentStream(callback: (delta: AgentStreamDelta) => void): () => void {
return this.on('agent', callback);
}
// === Internal ===
private handleFrame(frame: GatewayFrame, connectResolve?: (value: void) => void) {
if (frame.type === 'event') {
this.handleEvent(frame, connectResolve);
} else if (frame.type === 'res') {
this.handleResponse(frame);
}
}
private handleEvent(event: GatewayEvent, connectResolve?: (value: void) => void) {
// Handle connect challenge
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
this.performHandshake(event.payload?.nonce, connectResolve);
return;
}
// Dispatch to listeners
this.emitEvent(event.event, event.payload);
}
private performHandshake(_nonce: string, connectResolve?: (value: void) => void) {
const connectId = `connect_${Date.now()}`;
const connectReq: GatewayRequest = {
type: 'req',
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'zclaw-tauri',
version: '0.2.0',
platform: this.detectPlatform(),
mode: 'operator',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
auth: this.token ? { token: this.token } : {},
locale: 'zh-CN',
userAgent: 'zclaw-tauri/0.2.0',
device: { id: this.deviceId },
},
};
// Temporarily intercept the connect response
const originalHandler = this.ws!.onmessage;
this.ws!.onmessage = (evt) => {
try {
const frame = JSON.parse(evt.data);
if (frame.type === 'res' && frame.id === connectId) {
// Restore normal message handler
this.ws!.onmessage = originalHandler;
if (frame.ok) {
this.setState('connected');
this.reconnectAttempts = 0;
this.emitEvent('connected', frame.payload);
this.log('info', 'Connected to Gateway');
connectResolve?.();
} else {
this.log('error', `Handshake failed: ${JSON.stringify(frame.error)}`);
this.cleanup();
}
} else {
// Pass through non-connect frames
originalHandler?.call(this.ws!, evt);
}
} catch { /* ignore */ }
};
this.send(connectReq);
}
private handleResponse(res: GatewayResponse) {
const pending = this.pendingRequests.get(res.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(res.id);
if (res.ok) {
pending.resolve(res.payload);
} else {
pending.reject(new Error(JSON.stringify(res.error)));
}
}
}
private send(frame: GatewayFrame) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(frame));
}
}
private emitEvent(event: string, payload: any) {
const listeners = this.eventListeners.get(event);
if (listeners) {
for (const cb of listeners) {
try { cb(payload); } catch { /* ignore listener errors */ }
}
}
// Also emit wildcard
const wildcardListeners = this.eventListeners.get('*');
if (wildcardListeners) {
for (const cb of wildcardListeners) {
try { cb({ event, payload }); } catch { /* ignore */ }
}
}
}
private setState(state: ConnectionState) {
this.state = state;
this.onStateChange?.(state);
this.emitEvent('state', state);
}
private cleanup() {
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onclose = null;
this.ws.onerror = null;
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
try { this.ws.close(); } catch { /* ignore */ }
}
this.ws = null;
}
this.setState('disconnected');
}
private scheduleReconnect() {
this.reconnectAttempts++;
this.setState('reconnecting');
const delay = Math.min(this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), 30000);
this.reconnectTimer = window.setTimeout(async () => {
try {
await this.connect();
} catch { /* close handler will trigger another reconnect */ }
}, delay);
}
private cancelReconnect() {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private detectPlatform(): string {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('win')) return 'windows';
if (ua.includes('mac')) return 'macos';
return 'linux';
}
private log(level: string, message: string) {
this.onLog?.(level, message);
}
}
// Singleton instance
let _client: GatewayClient | null = null;
export function getGatewayClient(opts?: ConstructorParameters<typeof GatewayClient>[0]): GatewayClient {
if (!_client) {
_client = new GatewayClient(opts);
}
return _client;
}

View File

@@ -1,10 +1,26 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
export interface Message {
id: string;
role: 'user' | 'assistant';
role: 'user' | 'assistant' | 'tool';
content: string;
timestamp: Date;
streaming?: boolean;
toolName?: string;
toolInput?: string;
toolOutput?: string;
error?: string;
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
sessionKey: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface Agent {
@@ -18,25 +34,282 @@ export interface Agent {
interface ChatState {
messages: Message[];
conversations: Conversation[];
currentConversationId: string | null;
agents: Agent[];
currentAgent: Agent | null;
isStreaming: boolean;
currentModel: string;
sessionKey: string | null;
addMessage: (message: Message) => void;
updateMessage: (id: string, updates: Partial<Message>) => void;
setCurrentAgent: (agent: Agent) => void;
setCurrentModel: (model: string) => void;
sendMessage: (content: string) => Promise<void>;
initStreamListener: () => () => void;
newConversation: () => void;
switchConversation: (id: string) => void;
deleteConversation: (id: string) => void;
}
export const useChatStore = create<ChatState>((set) => ({
function generateConvId(): string {
return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
}
function deriveTitle(messages: Message[]): string {
const firstUser = messages.find(m => m.role === 'user');
if (firstUser) {
const text = firstUser.content.trim();
return text.length > 30 ? text.slice(0, 30) + '...' : text;
}
return '新对话';
}
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: '好的!选项 A 确认...',
time: '21:58',
lastMessage: '发送消息开始对话',
time: '',
},
],
currentAgent: null,
addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })),
isStreaming: false,
currentModel: 'glm-5',
sessionKey: null,
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
updateMessage: (id, updates) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, ...updates } : m
),
})),
setCurrentAgent: (agent) => set({ currentAgent: agent }),
}));
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];
}
}
set({
conversations,
messages: [],
sessionKey: null,
isStreaming: false,
currentConversationId: null,
});
},
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 target = conversations.find(c => c.id === id);
if (target) {
set({
conversations,
messages: [...target.messages],
sessionKey: target.sessionKey,
currentConversationId: target.id,
isStreaming: false,
});
}
},
deleteConversation: (id: string) => {
const state = get();
const conversations = state.conversations.filter(c => c.id !== id);
if (state.currentConversationId === id) {
set({ conversations, messages: [], sessionKey: null, currentConversationId: null, isStreaming: false });
} else {
set({ conversations });
}
},
sendMessage: async (content: string) => {
const { addMessage, currentModel, sessionKey } = get();
// Add user message
const userMsg: Message = {
id: `user_${Date.now()}`,
role: 'user',
content,
timestamp: new Date(),
};
addMessage(userMsg);
// Create placeholder assistant message for streaming
const assistantId = `assistant_${Date.now()}`;
const assistantMsg: Message = {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
streaming: true,
};
addMessage(assistantMsg);
set({ isStreaming: true });
try {
const client = getGatewayClient();
const result = await client.chat(content, {
sessionKey: sessionKey || undefined,
model: currentModel,
});
// Store session key for continuity
if (!sessionKey) {
set({ sessionKey: `session_${Date.now()}` });
}
// 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
),
}));
} catch (err: any) {
// Gateway not connected — show error in the assistant bubble
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId
? {
...m,
content: `⚠️ ${err.message || '无法连接 Gateway'}`,
streaming: false,
error: err.message,
}
: m
),
}));
}
},
initStreamListener: () => {
const client = getGatewayClient();
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);
if (!streamingMsg) return;
if (delta.stream === 'assistant' && delta.delta) {
// Append text delta to the streaming message
set((s) => ({
messages: s.messages.map((m) =>
m.id === streamingMsg.id
? { ...m, content: m.content + delta.delta }
: 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(),
toolName: delta.tool,
toolInput: delta.toolInput,
toolOutput: delta.toolOutput,
};
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) =>
m.id === streamingMsg.id
? {
...m,
streaming: false,
error: delta.phase === 'error' ? delta.error : undefined,
}
: m
),
}));
}
}
});
return unsubscribe;
},
}),
{
name: 'zclaw-chat-storage',
partialize: (state) => ({
conversations: state.conversations,
currentModel: state.currentModel,
}),
onRehydrateStorage: () => (state) => {
// Rehydrate Date objects from JSON strings
if (state?.conversations) {
for (const conv of state.conversations) {
conv.createdAt = new Date(conv.createdAt);
conv.updatedAt = new Date(conv.updatedAt);
for (const msg of conv.messages) {
msg.timestamp = new Date(msg.timestamp);
msg.streaming = false; // Never restore streaming state
}
}
}
},
},
),
);

View File

@@ -0,0 +1,217 @@
import { create } from 'zustand';
import { GatewayClient, ConnectionState, getGatewayClient } from '../lib/gateway-client';
interface GatewayLog {
timestamp: number;
level: string;
message: string;
}
interface Clone {
id: string;
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
createdAt: string;
}
interface UsageStats {
totalSessions: number;
totalMessages: number;
totalTokens: number;
byModel: Record<string, { messages: number; inputTokens: number; outputTokens: number }>;
}
interface ChannelInfo {
id: string;
type: string;
label: string;
status: 'active' | 'inactive' | 'error';
accounts?: number;
error?: string;
}
interface ScheduledTask {
id: string;
name: string;
schedule: string;
status: 'active' | 'paused' | 'completed' | 'error';
lastRun?: string;
nextRun?: string;
description?: string;
}
interface GatewayStore {
// Connection state
connectionState: ConnectionState;
gatewayVersion: string | null;
error: string | null;
logs: GatewayLog[];
// Data
clones: Clone[];
usageStats: UsageStats | null;
pluginStatus: any[];
channels: ChannelInfo[];
scheduledTasks: ScheduledTask[];
// Client reference
client: GatewayClient;
// Actions
connect: (url?: string, token?: string) => Promise<void>;
disconnect: () => void;
sendMessage: (message: string, sessionKey?: string) => Promise<{ runId: string }>;
loadClones: () => Promise<void>;
createClone: (opts: { name: string; role?: string; scenarios?: string[] }) => Promise<void>;
deleteClone: (id: string) => Promise<void>;
loadUsageStats: () => Promise<void>;
loadPluginStatus: () => Promise<void>;
loadChannels: () => Promise<void>;
loadScheduledTasks: () => Promise<void>;
clearLogs: () => void;
}
export const useGatewayStore = create<GatewayStore>((set, get) => {
const client = getGatewayClient();
// Wire up state change callback
client.onStateChange = (state) => {
set({ connectionState: state });
};
client.onLog = (level, message) => {
set((s) => ({
logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }],
}));
};
return {
connectionState: 'disconnected',
gatewayVersion: null,
error: null,
logs: [],
clones: [],
usageStats: null,
pluginStatus: [],
channels: [],
scheduledTasks: [],
client,
connect: async (url?: string, token?: string) => {
try {
set({ error: null });
const c = url ? getGatewayClient({ url, token }) : get().client;
await c.connect();
// Fetch initial data after connection
try {
const health = await c.health();
set({ gatewayVersion: health?.version });
} catch { /* health may not return version */ }
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
disconnect: () => {
get().client.disconnect();
},
sendMessage: async (message: string, sessionKey?: string) => {
const c = get().client;
return c.chat(message, { sessionKey });
},
loadClones: async () => {
try {
const result = await get().client.listClones();
set({ clones: result?.clones || [] });
} catch { /* ignore if method not available */ }
},
createClone: async (opts) => {
try {
await get().client.createClone(opts);
await get().loadClones();
} catch (err: any) {
set({ error: err.message });
}
},
deleteClone: async (id: string) => {
try {
await get().client.deleteClone(id);
await get().loadClones();
} catch (err: any) {
set({ error: err.message });
}
},
loadUsageStats: async () => {
try {
const stats = await get().client.getUsageStats();
set({ usageStats: stats });
} catch { /* ignore */ }
},
loadPluginStatus: async () => {
try {
const result = await get().client.getPluginStatus();
set({ pluginStatus: result?.plugins || [] });
} catch { /* ignore */ }
},
loadChannels: async () => {
const channels: { id: string; type: string; label: string; status: 'active' | 'inactive' | 'error'; accounts?: number; error?: string }[] = [];
try {
// Try listing channels from Gateway
const result = await get().client.listChannels();
if (result?.channels) {
set({ channels: result.channels });
return;
}
} catch { /* channels.list may not be available */ }
// Fallback: probe known channels individually
try {
const feishu = await get().client.getFeishuStatus();
channels.push({
id: 'feishu',
type: 'feishu',
label: '飞书 (Feishu)',
status: feishu?.configured ? 'active' : 'inactive',
accounts: feishu?.accounts || 0,
});
} catch {
channels.push({ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'inactive' });
}
// QQ channel (check if qqbot plugin is loaded)
const plugins = get().pluginStatus;
const qqPlugin = plugins.find((p: any) => (p.name || p.id || '').toLowerCase().includes('qqbot'));
if (qqPlugin) {
channels.push({
id: 'qqbot',
type: 'qqbot',
label: 'QQ 机器人',
status: qqPlugin.status === 'active' ? 'active' : 'inactive',
});
}
set({ channels });
},
loadScheduledTasks: async () => {
try {
const result = await get().client.listScheduledTasks();
set({ scheduledTasks: result?.tasks || [] });
} catch { /* ignore if heartbeat.tasks not available */ }
},
clearLogs: () => set({ logs: [] }),
};
});