docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective

Major changes:
- Shift from "OpenFang desktop client" to "independent AI Agent desktop app"
- Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?"
- Simplify project structure and tech stack sections
- Replace OpenClaw vs OpenFang comparison with unified backend approach
- Consolidate troubleshooting from scattered sections into organized FAQ
- Update Hands system documentation with 8 capabilities and status
- Stream
This commit is contained in:
iven
2026-03-20 19:30:09 +08:00
parent 3518fc8ece
commit 6f72442531
63 changed files with 8920 additions and 857 deletions

View File

@@ -119,7 +119,7 @@ function TypeBadge({ type }: { type: 'hand' | 'workflow' }) {
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
}`}>
{isHand ? 'Hand' : '工作流'}
{isHand ? '自主能力' : '工作流'}
</span>
);
}

View File

@@ -8,7 +8,7 @@
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useHandStore } from '../../store/handStore';
import { useGatewayStore } from '../../store/gatewayStore';
import { useWorkflowStore } from '../../store/workflowStore';
import {
type AutomationItem,
@@ -28,6 +28,7 @@ import {
Plus,
Calendar,
Search,
X,
} from 'lucide-react';
import { useToast } from '../ui/Toast';
@@ -50,14 +51,14 @@ export function AutomationPanel({
onSelect,
showBatchActions = true,
}: AutomationPanelProps) {
// Store state
const hands = useHandStore(s => s.hands);
const workflows = useWorkflowStore(s => s.workflows);
const isLoadingHands = useHandStore(s => s.isLoading);
const isLoadingWorkflows = useWorkflowStore(s => s.isLoading);
const loadHands = useHandStore(s => s.loadHands);
const loadWorkflows = useWorkflowStore(s => s.loadWorkflows);
const triggerHand = useHandStore(s => s.triggerHand);
// Store state - use gatewayStore which has the actual data
const hands = useGatewayStore(s => s.hands);
const workflows = useGatewayStore(s => s.workflows);
const isLoading = useGatewayStore(s => s.isLoading);
const loadHands = useGatewayStore(s => s.loadHands);
const loadWorkflows = useGatewayStore(s => s.loadWorkflows);
const triggerHand = useGatewayStore(s => s.triggerHand);
// workflowStore for triggerWorkflow (not in gatewayStore)
const triggerWorkflow = useWorkflowStore(s => s.triggerWorkflow);
// UI state
@@ -66,6 +67,8 @@ export function AutomationPanel({
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
const [showWorkflowDialog, setShowWorkflowDialog] = useState(false);
const [showSchedulerDialog, setShowSchedulerDialog] = useState(false);
const { toast } = useToast();
@@ -115,6 +118,15 @@ export function AutomationPanel({
setSelectedIds(new Set());
}, []);
// Workflow dialog handlers
const handleCreateWorkflow = useCallback(() => {
setShowWorkflowDialog(true);
}, []);
const handleSchedulerManage = useCallback(() => {
setShowSchedulerDialog(true);
}, []);
// Execute handler
const handleExecute = useCallback(async (item: AutomationItem, params?: Record<string, unknown>) => {
setExecutingIds(prev => new Set(prev).add(item.id));
@@ -173,8 +185,6 @@ export function AutomationPanel({
toast('数据已刷新', 'success');
}, [loadHands, loadWorkflows, toast]);
const isLoading = isLoadingHands || isLoadingWorkflows;
return (
<div className="flex flex-col h-full">
{/* Header */}
@@ -198,12 +208,14 @@ export function AutomationPanel({
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleCreateWorkflow}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
title="新建工作流"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={handleSchedulerManage}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
title="调度管理"
>
@@ -271,6 +283,96 @@ export function AutomationPanel({
}}
/>
)}
{/* Create Workflow Dialog */}
{showWorkflowDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"></h3>
<button
onClick={() => setShowWorkflowDialog(false)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="text"
placeholder="输入工作流名称..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
placeholder="描述这个工作流的用途..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowWorkflowDialog(false)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
</button>
<button
onClick={() => {
toast('工作流创建功能开发中', 'info');
setShowWorkflowDialog(false);
}}
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500"
>
</button>
</div>
</div>
</div>
)}
{/* Scheduler Dialog */}
{showSchedulerDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"></h3>
<button
onClick={() => setShowSchedulerDialog(false)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4">
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Calendar className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p></p>
<p className="text-sm mt-1">Cron </p>
</div>
</div>
<div className="flex justify-end p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowSchedulerDialog(false)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -298,12 +298,12 @@ export function ExecutionResult({
{statusConfig.label}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{itemType === 'hand' ? 'Hand' : '工作流'}
{itemType === 'hand' ? '自主能力' : '工作流'}
</span>
</div>
{run.runId && (
<span className="text-xs text-gray-400 dark:text-gray-500">
Run ID: {run.runId}
ID: {run.runId}
</span>
)}
</div>

View File

@@ -168,7 +168,7 @@ export function BrowserHandCard({ onOpenSettings }: BrowserHandCardProps) {
className={cn(
'flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg font-medium transition-colors',
activeSessionId && !execution.isRunning
? 'bg-blue-600 text-white hover:bg-blue-700'
? 'bg-gray-700 dark:bg-gray-600 text-white hover:bg-gray-800 dark:hover:bg-gray-500'
: 'bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500 cursor-not-allowed'
)}
>

View File

@@ -2,21 +2,19 @@
import { motion, AnimatePresence } from 'framer-motion';
import { useChatStore, Message } from '../store/chatStore';
import { useGatewayStore } from '../store/gatewayStore';
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare } from 'lucide-react';
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
import { Button, EmptyState } from './ui';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { MessageSearch } from './MessageSearch';
const MODELS = ['glm-5', 'qwen3.5-plus', 'kimi-k2.5', 'minimax-m2.5'];
export function ChatArea() {
const {
messages, currentAgent, isStreaming, currentModel,
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation,
} = useChatStore();
const { connectionState, clones } = useGatewayStore();
const { connectionState, clones, models } = useGatewayStore();
const [input, setInput] = useState('');
const [showModelPicker, setShowModelPicker] = useState(false);
@@ -213,16 +211,22 @@ export function ChatArea() {
<ChevronDown className="w-3 h-3" />
</Button>
{showModelPicker && (
<div className="absolute bottom-full right-8 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 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 dark:hover:bg-gray-700 ${model === currentModel ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-700 dark:text-gray-300'}`}
>
{model}
</button>
))}
<div className="absolute bottom-full right-8 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[160px] max-h-48 overflow-y-auto z-10">
{models.length > 0 ? (
models.map((model) => (
<button
key={model.id}
onClick={() => { setCurrentModel(model.id); setShowModelPicker(false); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-gray-700 ${model.id === currentModel ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-700 dark:text-gray-300'}`}
>
{model.name}
</button>
))
) : (
<div className="px-3 py-2 text-xs text-gray-400">
{connected ? '加载中...' : '未连接 Gateway'}
</div>
)}
</div>
)}
<Button
@@ -246,6 +250,105 @@ export function ChatArea() {
);
}
/** Code block with copy and download functionality */
function CodeBlock({ code, language, index }: { code: string; language: string; index: number }) {
const [copied, setCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
// Infer filename from language or content
const inferFilename = (): string => {
const extMap: Record<string, string> = {
javascript: 'js', typescript: 'ts', python: 'py', rust: 'rs',
go: 'go', java: 'java', cpp: 'cpp', c: 'c', csharp: 'cs',
html: 'html', css: 'css', scss: 'scss', json: 'json',
yaml: 'yaml', yml: 'yaml', xml: 'xml', sql: 'sql',
shell: 'sh', bash: 'sh', powershell: 'ps1',
markdown: 'md', md: 'md', dockerfile: 'dockerfile',
};
// Check if language contains a filename (e.g., ```app.tsx)
if (language.includes('.') || language.includes('/')) {
return language;
}
// Check for common patterns in code
const codeLower = code.toLowerCase();
if (codeLower.includes('<!doctype html') || codeLower.includes('<html')) {
return 'index.html';
}
if (codeLower.includes('package.json') || (codeLower.includes('"name"') && codeLower.includes('"version"'))) {
return 'package.json';
}
if (codeLower.startsWith('{') && (codeLower.includes('"import"') || codeLower.includes('"export"'))) {
return 'config.json';
}
// Use language extension
const ext = extMap[language.toLowerCase()] || language.toLowerCase();
return `code-${index + 1}.${ext || 'txt'}`;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleDownload = () => {
setDownloading(true);
try {
const filename = inferFilename();
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download:', err);
}
setTimeout(() => setDownloading(false), 500);
};
return (
<div className="relative group my-2">
<pre className="bg-gray-900 text-gray-100 rounded-lg p-3 overflow-x-auto text-xs font-mono leading-relaxed">
{language && (
<div className="text-gray-500 text-[10px] mb-1 uppercase flex items-center justify-between">
<span>{language}</span>
</div>
)}
<code>{code}</code>
</pre>
{/* Action buttons - show on hover */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={handleCopy}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
title="复制代码"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<button
onClick={handleDownload}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
title="下载文件"
disabled={downloading}
>
<Download className={`w-3.5 h-3.5 ${downloading ? 'animate-pulse' : ''}`} />
</button>
</div>
</div>
);
}
/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
function renderMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
@@ -266,10 +369,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
}
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>
<CodeBlock key={nodes.length} code={codeLines.join('\n')} language={lang} index={nodes.length} />
);
continue;
}
@@ -354,6 +454,22 @@ function MessageBubble({ message }: { message: Message }) {
// 思考中状态streaming 且内容为空时显示思考指示器
const isThinking = message.streaming && !message.content;
// Download message as Markdown file
const handleDownloadMessage = () => {
if (!message.content) return;
const timestamp = new Date().toISOString().slice(0, 10);
const filename = `message-${timestamp}.md`;
const blob = new Blob([message.content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className={`flex gap-4 ${isUser ? 'justify-end' : ''}`}>
<div
@@ -373,7 +489,7 @@ function MessageBubble({ message }: { message: Message }) {
<span className="text-sm">...</span>
</div>
) : (
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'}`}>
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700 dark:text-gray-200'}`}>
{message.content
? (isUser ? message.content : renderMarkdown(message.content))
@@ -383,6 +499,16 @@ function MessageBubble({ message }: { message: Message }) {
{message.error && (
<p className="text-xs text-red-500 mt-2">{message.error}</p>
)}
{/* Download button for AI messages - show on hover */}
{!isUser && message.content && !message.streaming && (
<button
onClick={handleDownloadMessage}
className="absolute top-2 right-2 p-1.5 bg-gray-200/80 dark:bg-gray-700/80 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors opacity-0 group-hover:opacity-100"
title="下载为 Markdown"
>
<Download className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>

View File

@@ -62,13 +62,13 @@ function validateParameter(param: HandParameter, value: unknown): ValidationResu
// Required check
if (param.required) {
if (value === undefined || value === null || value === '') {
return { isValid: false, error: `${param.label} is required` };
return { isValid: false, error: `${param.label} 为必填项` };
}
if (Array.isArray(value) && value.length === 0) {
return { isValid: false, error: `${param.label} is required` };
return { isValid: false, error: `${param.label} 为必填项` };
}
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
return { isValid: false, error: `${param.label} is required` };
return { isValid: false, error: `${param.label} 为必填项` };
}
}
@@ -81,26 +81,26 @@ function validateParameter(param: HandParameter, value: unknown): ValidationResu
switch (param.type) {
case 'number':
if (typeof value !== 'number' || isNaN(value)) {
return { isValid: false, error: `${param.label} must be a valid number` };
return { isValid: false, error: `${param.label} 必须是有效数字` };
}
if (param.min !== undefined && value < param.min) {
return { isValid: false, error: `${param.label} must be at least ${param.min}` };
return { isValid: false, error: `${param.label} 不能小于 ${param.min}` };
}
if (param.max !== undefined && value > param.max) {
return { isValid: false, error: `${param.label} must be at most ${param.max}` };
return { isValid: false, error: `${param.label} 不能大于 ${param.max}` };
}
break;
case 'text':
case 'textarea':
if (typeof value !== 'string') {
return { isValid: false, error: `${param.label} must be text` };
return { isValid: false, error: `${param.label} 必须是文本` };
}
if (param.pattern) {
try {
const regex = new RegExp(param.pattern);
if (!regex.test(value)) {
return { isValid: false, error: `${param.label} format is invalid` };
return { isValid: false, error: `${param.label} 格式不正确` };
}
} catch {
// Invalid regex pattern, skip validation
@@ -110,19 +110,19 @@ function validateParameter(param: HandParameter, value: unknown): ValidationResu
case 'array':
if (!Array.isArray(value)) {
return { isValid: false, error: `${param.label} must be an array` };
return { isValid: false, error: `${param.label} 必须是数组` };
}
break;
case 'object':
if (typeof value !== 'object' || Array.isArray(value)) {
return { isValid: false, error: `${param.label} must be an object` };
return { isValid: false, error: `${param.label} 必须是对象` };
}
try {
// Try to stringify to validate JSON
JSON.stringify(value);
} catch {
return { isValid: false, error: `${param.label} contains invalid JSON` };
return { isValid: false, error: `${param.label} 包含无效的 JSON` };
}
break;
@@ -210,7 +210,7 @@ function TextParamInput({ param, value, onChange, disabled, error }: ParamInputP
onChange={(e) => onChange(e.target.value)}
placeholder={param.placeholder}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
@@ -230,7 +230,7 @@ function NumberParamInput({ param, value, onChange, disabled, error }: ParamInpu
min={param.min}
max={param.max}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
@@ -245,10 +245,10 @@ function BooleanParamInput({ param, value, onChange, disabled }: ParamInputProps
checked={(value as boolean) ?? false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-4 h-4 text-gray-600 border-gray-300 rounded focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{param.placeholder || 'Enabled'}
{param.placeholder || '启用'}
</span>
</label>
);
@@ -264,7 +264,7 @@ function SelectParamInput({ param, value, onChange, disabled, error }: ParamInpu
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
>
<option value="">{param.placeholder || '-- Select --'}</option>
<option value="">{param.placeholder || '-- 请选择 --'}</option>
{param.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
@@ -331,7 +331,7 @@ function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInput
value={item}
onChange={(e) => handleUpdateItem(index, e.target.value)}
disabled={disabled}
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-gray-400 disabled:opacity-50"
/>
<button
type="button"
@@ -353,7 +353,7 @@ function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInput
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={param.placeholder || 'Add item...'}
placeholder={param.placeholder || '添加项目...'}
disabled={disabled}
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
/>
@@ -361,7 +361,7 @@ function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInput
type="button"
onClick={handleAddItem}
disabled={disabled || !newItem.trim()}
className="p-1 text-blue-500 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1 text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
</button>
@@ -408,10 +408,10 @@ function ObjectParamInput({ param, value, onChange, disabled, error }: ParamInpu
onChange(result.data as Record<string, unknown>);
setParseError(null);
} else {
setParseError('Value must be a JSON object');
setParseError('值必须是 JSON 对象');
}
} else {
setParseError('Invalid JSON format');
setParseError('JSON 格式无效');
}
};
@@ -423,7 +423,7 @@ function ObjectParamInput({ param, value, onChange, disabled, error }: ParamInpu
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{isExpanded ? 'Collapse' : 'Expand'} JSON Editor
{isExpanded ? '收起' : '展开'} JSON
</button>
{isExpanded && (
@@ -603,7 +603,7 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
<Save className="w-3.5 h-3.5" />
Save Preset
</button>
<button
type="button"
@@ -612,7 +612,7 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Preset ({presets.length})
({presets.length})
</button>
</div>
@@ -620,15 +620,15 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
{showSaveDialog && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Preset Name
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="My preset..."
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="我的预设..."
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSavePreset();
if (e.key === 'Escape') setShowSaveDialog(false);
@@ -639,16 +639,16 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
type="button"
onClick={handleSavePreset}
disabled={!presetName.trim()}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save
</button>
<button
type="button"
onClick={() => setShowSaveDialog(false)}
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Cancel
</button>
</div>
</div>
@@ -658,7 +658,7 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
{showPresetList && presets.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Available Presets
</label>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{presets.map((preset) => (
@@ -678,9 +678,9 @@ function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManager
<button
type="button"
onClick={() => handleLoadPreset(preset)}
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
className="px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-900/20 rounded"
>
Load
</button>
<button
type="button"
@@ -753,7 +753,7 @@ export function HandParamsForm({
if (parameters.length === 0) {
return (
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
No parameters required for this Hand.
</div>
);
}

View File

@@ -336,7 +336,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
<button
onClick={handleActivateClick}
disabled={!canActivate || hasUnmetRequirements || isActivating}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isActivating ? (
<>
@@ -428,7 +428,7 @@ function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps)
<button
onClick={() => onActivate(hand)}
disabled={!canActivate || hasUnmetRequirements || isActivating}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
{isActivating ? (
<>

View File

@@ -1,36 +1,86 @@
import { useEffect, useState } from 'react';
import { useState, useEffect } from 'react';
import { getStoredGatewayToken, getStoredGatewayUrl } from '../../lib/gateway-client';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
import { silentErrorHandler } from '../../lib/error-utils';
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X } from 'lucide-react';
// Helper function to format context window size
function formatContextWindow(tokens?: number): string {
if (!tokens) return '';
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
// 自定义模型数据结构
interface CustomModel {
id: string;
name: string;
provider: string;
apiKey?: string;
apiProtocol: 'openai' | 'anthropic' | 'custom';
baseUrl?: string;
isDefault?: boolean;
createdAt: string;
}
// 可用的 Provider 列表
const AVAILABLE_PROVIDERS = [
{ id: 'zhipu', name: '智谱 (ZhipuAI)', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' },
{ id: 'qwen', name: '百炼/通义千问 (Qwen)', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
{ id: 'kimi', name: 'Kimi (Moonshot)', baseUrl: 'https://api.moonshot.cn/v1' },
{ id: 'minimax', name: 'MiniMax', baseUrl: 'https://api.minimax.chat/v1' },
{ id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1' },
{ id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
{ id: 'custom', name: '自定义', baseUrl: '' },
];
const STORAGE_KEY = 'zclaw-custom-models';
// 从 localStorage 加载自定义模型
function loadCustomModels(): CustomModel[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {
// ignore
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`;
return [];
}
// 保存自定义模型到 localStorage
function saveCustomModels(models: CustomModel[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(models));
} catch {
// ignore
}
return `${tokens}`;
}
export function ModelsAPI() {
const { connectionState, connect, disconnect, quickConfig, saveQuickConfig, models, modelsLoading, modelsError, loadModels } = useGatewayStore();
const { connectionState, connect, disconnect, quickConfig, loadModels } = useGatewayStore();
const { currentModel, setCurrentModel } = useChatStore();
const [gatewayUrl, setGatewayUrl] = useState(getStoredGatewayUrl());
const [gatewayToken, setGatewayToken] = useState(quickConfig.gatewayToken || getStoredGatewayToken());
// 自定义模型状态
const [customModels, setCustomModels] = useState<CustomModel[]>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [editingModel, setEditingModel] = useState<CustomModel | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
// 表单状态
const [formData, setFormData] = useState({
provider: 'zhipu',
modelId: '',
displayName: '',
apiKey: '',
apiProtocol: 'openai' as 'openai' | 'anthropic' | 'custom',
baseUrl: '',
});
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
// Load models when connected
// 加载自定义模型
useEffect(() => {
if (connected && models.length === 0 && !modelsLoading) {
loadModels();
}
}, [connected, models.length, modelsLoading, loadModels]);
setCustomModels(loadCustomModels());
}, []);
useEffect(() => {
setGatewayUrl(quickConfig.gatewayUrl || getStoredGatewayUrl());
@@ -45,196 +95,335 @@ export function ModelsAPI() {
).catch(silentErrorHandler('ModelsAPI')), 500);
};
const handleSaveGatewaySettings = () => {
saveQuickConfig({
gatewayUrl,
gatewayToken,
}).catch(silentErrorHandler('ModelsAPI'));
// 打开添加模型弹窗
const handleOpenAddModal = () => {
setFormData({
provider: 'zhipu',
modelId: '',
displayName: '',
apiKey: '',
apiProtocol: 'openai',
baseUrl: AVAILABLE_PROVIDERS[0].baseUrl,
});
setEditingModel(null);
setShowAddModal(true);
};
const handleRefreshModels = () => {
// 打开编辑模型弹窗
const handleOpenEditModal = (model: CustomModel) => {
setFormData({
provider: model.provider,
modelId: model.id,
displayName: model.name,
apiKey: model.apiKey || '',
apiProtocol: model.apiProtocol,
baseUrl: model.baseUrl || '',
});
setEditingModel(model);
setShowAddModal(true);
};
// 保存模型
const handleSaveModel = () => {
if (!formData.modelId.trim()) return;
const newModel: CustomModel = {
id: formData.modelId.trim(),
name: formData.displayName.trim() || formData.modelId.trim(),
provider: formData.provider,
apiKey: formData.apiKey.trim(),
apiProtocol: formData.apiProtocol,
baseUrl: formData.baseUrl.trim() || AVAILABLE_PROVIDERS.find(p => p.id === formData.provider)?.baseUrl,
createdAt: editingModel?.createdAt || new Date().toISOString(),
};
let updatedModels: CustomModel[];
if (editingModel) {
// 编辑模式
updatedModels = customModels.map(m => m.id === editingModel.id ? newModel : m);
} else {
// 添加模式
updatedModels = [...customModels, newModel];
}
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
setShowAddModal(false);
setEditingModel(null);
// 刷新模型列表
loadModels();
};
// 删除模型
const handleDeleteModel = (modelId: string) => {
const updatedModels = customModels.filter(m => m.id !== modelId);
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
};
// 设为默认模型
const handleSetDefault = (modelId: string) => {
setCurrentModel(modelId);
// 更新自定义模型的默认状态
const updatedModels = customModels.map(m => ({
...m,
isDefault: m.id === modelId,
}));
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
};
// Provider 变更时更新 baseUrl
const handleProviderChange = (providerId: string) => {
const provider = AVAILABLE_PROVIDERS.find(p => p.id === providerId);
setFormData({
...formData,
provider: providerId,
baseUrl: provider?.baseUrl || '',
});
};
return (
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold text-gray-900"> API</h1>
<h1 className="text-xl font-bold text-gray-900 dark:text-white"> API</h1>
<button
onClick={handleReconnect}
disabled={connecting}
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"
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 px-3 py-1.5 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
{connecting ? '连接中...' : '重新连接'}
</button>
</div>
{/* Gateway 连接状态 */}
<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">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">Gateway </h3>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm space-y-3">
<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 text-gray-500 dark:text-gray-400"></span>
<span className={`text-sm ${connected ? 'text-green-600' : connecting ? 'text-yellow-600' : 'text-gray-400'}`}>
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400"></span>
<span className="text-sm font-medium text-orange-600">{currentModel || '未选择'}</span>
</div>
</div>
</div>
{/* 内置模型 */}
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider"></h3>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300">ZCLAW </span>
<span className="text-xs text-gray-400"> Gateway </span>
</div>
</div>
</div>
{/* 自定义模型 */}
<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>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400"></span>
{connected && (
<button
onClick={handleRefreshModels}
disabled={modelsLoading}
className="text-xs text-orange-600 hover:text-orange-700 disabled:opacity-50"
>
{modelsLoading ? '加载中...' : '刷新'}
</button>
)}
</div>
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider"></h3>
<button
onClick={handleOpenAddModal}
className="text-xs text-orange-600 hover:text-orange-700 flex items-center gap-1"
>
<Plus className="w-3 h-3" />
</button>
</div>
{/* Loading state */}
{modelsLoading && (
<div className="bg-white rounded-xl border border-gray-200 p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-orange-500"></div>
<span className="ml-3 text-sm text-gray-500">...</span>
</div>
{customModels.length === 0 ? (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm text-center">
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"></p>
</div>
)}
{/* Error state */}
{modelsError && !modelsLoading && (
<div className="bg-white rounded-xl border border-red-200 p-4 shadow-sm">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-sm font-medium text-red-800"></p>
<p className="text-xs text-red-600 mt-1">{modelsError}</p>
<button
onClick={handleRefreshModels}
className="mt-2 text-xs text-red-600 hover:text-red-700 underline"
>
</button>
</div>
</div>
</div>
)}
{/* Not connected state */}
{!connected && !modelsLoading && !modelsError && (
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="text-center">
<svg className="w-8 h-8 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p className="text-sm text-gray-500"> Gateway </p>
</div>
</div>
)}
{/* Model list */}
{connected && !modelsLoading && !modelsError && models.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm">
{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="flex items-center gap-2 mt-1">
{model.provider && (
<span className="text-xs text-gray-400">{model.provider}</span>
)}
{model.contextWindow && (
<span className="text-xs text-gray-400">
{model.provider && '|'}
{formatContextWindow(model.contextWindow)}
</span>
)}
{model.maxOutput && (
<span className="text-xs text-gray-400">
{formatContextWindow(model.maxOutput)}
</span>
)}
</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 className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl divide-y divide-gray-100 dark:divide-gray-700 shadow-sm">
{customModels.map((model) => (
<div
key={model.id}
className={`flex justify-between items-center p-4 ${currentModel === model.id ? 'bg-orange-50/50 dark:bg-orange-900/10' : ''}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{model.name}</span>
{currentModel === model.id && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded"></span>
)}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{AVAILABLE_PROVIDERS.find(p => p.id === model.provider)?.name || model.provider}
{model.apiKey ? ' · 已配置 API Key' : ' · 未配置 API Key'}
</div>
</div>
);
})}
<div className="flex items-center gap-2 text-xs">
{currentModel !== model.id && (
<button
onClick={() => handleSetDefault(model.id)}
className="text-orange-600 hover:underline flex items-center gap-1"
>
<Star className="w-3 h-3" />
</button>
)}
<button
onClick={() => handleOpenEditModal(model)}
className="text-gray-500 dark:text-gray-400 hover:underline flex items-center gap-1"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={() => handleDeleteModel(model.id)}
className="text-red-500 hover:underline flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Empty state */}
{connected && !modelsLoading && !modelsError && models.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="text-center">
<svg className="w-8 h-8 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
</svg>
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1"> Gateway Provider </p>
{/* 添加/编辑模型弹窗 */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowAddModal(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
{/* 弹窗头部 */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700 p-6 flex justify-between items-center z-10">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{editingModel ? '编辑模型' : '添加模型'}
</h3>
<button
onClick={() => setShowAddModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 弹窗内容 */}
<div className="p-6 space-y-4">
{/* 警告提示 */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-100 dark:border-yellow-800 rounded-lg p-3 text-xs text-yellow-800 dark:text-yellow-200 flex items-start gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>使</span>
</div>
{/* 服务商 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">* </label>
<select
value={formData.provider}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
>
{AVAILABLE_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* 模型 ID */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">* ID</label>
<input
type="text"
value={formData.modelId}
onChange={(e) => setFormData({ ...formData, modelId: e.target.value })}
placeholder="如glm-4-plus"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{/* 显示名称 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
placeholder="如GLM-4-Plus"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{/* API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API Key</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="请填写 API Key"
className="w-full px-3 py-2 pr-10 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* API 协议 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API </label>
<select
value={formData.apiProtocol}
onChange={(e) => setFormData({ ...formData, apiProtocol: e.target.value as 'openai' | 'anthropic' | 'custom' })}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="custom"></option>
</select>
</div>
{/* Base URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Base URL</label>
<input
type="text"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.example.com/v1"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
</div>
{/* 弹窗底部 */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 p-6 flex justify-end gap-3">
<button
onClick={() => setShowAddModal(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-sm"
>
</button>
<button
onClick={handleSaveModel}
disabled={!formData.modelId.trim()}
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingModel ? '保存' : '添加'}
</button>
</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>
</div>
<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>
</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(silentErrorHandler('ModelsAPI')); }}
className="w-full bg-transparent border-none outline-none"
/>
<input
type="password"
value={gatewayToken}
onChange={(e) => setGatewayToken(e.target.value)}
onBlur={() => { saveQuickConfig({ gatewayToken }).catch(silentErrorHandler('ModelsAPI')); }}
placeholder="Gateway auth token"
className="w-full bg-transparent border-none outline-none"
/>
</div>
)}
</div>
);
}

View File

@@ -5,10 +5,7 @@ import {
Search, Sparkles, ChevronRight, X
} from 'lucide-react';
import { CloneManager } from './CloneManager';
import { AutomationPanel } from './Automation';
import { TeamList } from './TeamList';
import { SwarmDashboard } from './SwarmDashboard';
import { SkillMarket } from './SkillMarket';
import { useGatewayStore } from '../store/gatewayStore';
import { containerVariants, defaultTransition } from '../lib/animations';
@@ -75,7 +72,7 @@ export function Sidebar({
placeholder="搜索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-8 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all text-gray-700 dark:text-gray-300 placeholder-gray-400"
className="w-full pl-9 pr-8 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:border-gray-400 focus:ring-1 focus:ring-gray-400 transition-all text-gray-700 dark:text-gray-300 placeholder-gray-400"
/>
{searchQuery && (
<button
@@ -91,10 +88,13 @@ export function Sidebar({
{/* 新对话按钮 */}
<div className="px-3 py-2">
<button
onClick={onNewChat}
onClick={() => {
setActiveTab('clones');
onNewChat?.();
}}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-700 dark:text-gray-300 transition-colors group"
>
<Sparkles className="w-5 h-5 text-emerald-500" />
<Sparkles className="w-5 h-5 text-gray-500" />
<span className="font-medium"></span>
</button>
</div>
@@ -123,7 +123,7 @@ export function Sidebar({
{/* 分隔线 */}
<div className="my-3 mx-3 border-t border-gray-100 dark:border-gray-800" />
{/* 内容区域 */}
{/* 内容区域 - 只显示分身、团队、协作的内容,自动化和技能在主内容区显示 */}
<div className="flex-1 overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
@@ -136,17 +136,13 @@ export function Sidebar({
className="h-full overflow-y-auto"
>
{activeTab === 'clones' && <CloneManager />}
{activeTab === 'automation' && (
<AutomationPanel />
)}
{activeTab === 'skills' && <SkillMarket />}
{/* skills、automation 和 swarm 不在侧边栏显示内容,由主内容区显示 */}
{activeTab === 'team' && (
<TeamList
selectedTeamId={selectedTeamId}
onSelectTeam={handleSelectTeam}
/>
)}
{activeTab === 'swarm' && <SwarmDashboard />}
</motion.div>
</AnimatePresence>
</div>
@@ -157,7 +153,7 @@ export function Sidebar({
onClick={onOpenSettings}
className="flex items-center gap-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 p-2 rounded-lg transition-colors"
>
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-cyan-500 rounded-full flex items-center justify-center text-white font-bold shadow-sm">
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center text-white font-bold shadow-sm">
{userName?.charAt(0) || '用'}
</div>
<span className="flex-1 text-left text-sm font-medium text-gray-700 dark:text-gray-300 truncate">

View File

@@ -225,7 +225,7 @@ function SkillCard({
e.stopPropagation();
onInstall();
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 hover:bg-gray-800 dark:hover:bg-gray-500 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
@@ -401,7 +401,7 @@ export function SkillMarket({
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="搜索技能、能力、触发词..."
className="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
className="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-gray-400 focus:border-transparent text-sm"
/>
</div>

View File

@@ -120,7 +120,7 @@ export function SkillCard({
px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
${skill.installed
? 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
: 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-700 dark:bg-gray-600 text-white hover:bg-gray-800 dark:hover:bg-gray-500'
}
`}
>

View File

@@ -241,7 +241,7 @@ function TaskCard({
<div
className={`border rounded-lg overflow-hidden transition-all ${
isSelected
? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-500/20'
? 'border-orange-500 dark:border-orange-400 ring-2 ring-orange-500/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
@@ -349,7 +349,7 @@ function CreateTaskForm({
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="描述需要协作完成的任务..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
rows={3}
/>
</div>
@@ -369,7 +369,7 @@ function CreateTaskForm({
onClick={() => setStyle(s)}
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${
style === s
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 text-gray-600 dark:text-gray-400'
}`}
>
@@ -392,7 +392,7 @@ function CreateTaskForm({
<button
type="submit"
disabled={!description.trim()}
className="px-4 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-1.5"
className="px-4 py-1.5 text-sm bg-orange-500 hover:bg-orange-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-1.5"
>
<Sparkles className="w-4 h-4" />
@@ -477,7 +477,7 @@ export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardP
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-blue-500" />
<Users className="w-5 h-5 text-orange-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
</div>
<div className="flex items-center gap-2">
@@ -491,7 +491,7 @@ export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardP
</button>
<button
onClick={() => setShowCreateForm((prev) => !prev)}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
@@ -525,7 +525,7 @@ export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardP
onClick={() => setFilter(f)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filter === f
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
@@ -567,7 +567,7 @@ export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardP
</p>
<button
onClick={() => setShowCreateForm(true)}
className="mt-2 text-blue-500 hover:text-blue-600 text-sm"
className="mt-2 text-orange-500 hover:text-orange-600 text-sm"
>
</button>

View File

@@ -30,7 +30,11 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
const [isCreating, setIsCreating] = useState(false);
useEffect(() => {
loadTeams();
try {
loadTeams();
} catch (err) {
console.error('[TeamList] Failed to load teams:', err);
}
}, [loadTeams]);
const handleSelectTeam = (teamId: string) => {
@@ -93,12 +97,17 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
}
};
// Merge clones and agents for display
const availableAgents = clones.length > 0 ? clones : agents.map(a => ({
id: a.id,
name: a.name,
role: '默认助手',
}));
// Merge clones and agents for display - normalize to common type with defensive checks
const availableAgents: Array<{ id: string; name: string; role?: string }> =
(clones && clones.length > 0)
? clones.map(c => ({ id: c.id, name: c.name, role: c.role }))
: (agents && agents.length > 0)
? agents.map(a => ({
id: a.id,
name: a.name,
role: '默认助手',
}))
: [];
return (
<div className="h-full flex flex-col">
@@ -106,12 +115,12 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Teams
</h3>
<button
onClick={() => setShowCreateModal(true)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
title="Create Team"
title="创建团队"
>
<Plus className="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" />
</button>
@@ -124,7 +133,7 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-80 max-h-[90vh] overflow-y-auto">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Create Team</h3>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"></h3>
<button
onClick={() => setShowCreateModal(false)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
@@ -138,51 +147,51 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
{/* Team Name */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Team Name *
*
</label>
<input
type="text"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="e.g., Dev Team Alpha"
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="例如:开发团队 Alpha"
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
</div>
{/* Team Description */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={teamDescription}
onChange={(e) => setTeamDescription(e.target.value)}
placeholder="What will this team work on?"
placeholder="这个团队将负责什么工作?"
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none"
/>
</div>
{/* Collaboration Pattern */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Collaboration Pattern
</label>
<select
value={teamPattern}
onChange={(e) => setTeamPattern(e.target.value as typeof teamPattern)}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-gray-400"
>
<option value="sequential">Sequential (Task by task)</option>
<option value="parallel">Parallel (Concurrent work)</option>
<option value="pipeline">Pipeline (Output feeds next)</option>
<option value="sequential"></option>
<option value="parallel"></option>
<option value="pipeline">线</option>
</select>
</div>
{/* Agent Selection */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Agents ({selectedAgents.length} selected) *
( {selectedAgents.length} ) *
</label>
<div className="space-y-2 max-h-40 overflow-y-auto">
{availableAgents.map((agent) => (
@@ -195,7 +204,7 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
: 'bg-gray-50 dark:bg-gray-700 border border-transparent hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
>
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-orange-400 to-red-500 flex items-center justify-center text-white text-xs">
<div className="w-6 h-6 rounded-full bg-gray-600 flex items-center justify-center text-white text-xs">
<Bot className="w-3 h-3" />
</div>
<span className="text-gray-900 dark:text-white truncate">{agent.name}</span>
@@ -206,7 +215,7 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
))}
{availableAgents.length === 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
No agents available. Create an agent first.
</p>
)}
</div>
@@ -219,14 +228,14 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleCreateTeam}
disabled={!teamName.trim() || selectedAgents.length === 0 || isCreating}
className="flex-1 px-4 py-2 text-sm text-white bg-blue-500 rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex-1 px-4 py-2 text-sm text-white bg-gray-700 dark:bg-gray-600 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isCreating ? 'Creating...' : 'Create'}
{isCreating ? '创建中...' : '创建'}
</button>
</div>
</div>
@@ -236,15 +245,15 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
{/* Team List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-gray-400 text-sm">Loading...</div>
) : teams.length === 0 ? (
<div className="p-4 text-center text-gray-400 text-sm">...</div>
) : !Array.isArray(teams) || teams.length === 0 ? (
<div className="p-4 text-center">
<Users className="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p className="text-xs text-gray-400 dark:text-gray-500">
No teams yet
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Click + to create one
+
</p>
</div>
) : (
@@ -271,7 +280,7 @@ export function TeamList({ onSelectTeam, selectedTeamId }: TeamListProps) {
{team.members.length}
</span>
<span>·</span>
<span>{team.tasks.length} tasks</span>
<span>{team.tasks.length} </span>
</div>
</button>
))}

View File

@@ -402,7 +402,7 @@ export function WorkflowEditor({ workflow, isOpen, onClose, onSave, isSaving }:
</p>
<button
onClick={handleAddStep}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500"
>
<Plus className="w-4 h-4" />
@@ -438,7 +438,7 @@ export function WorkflowEditor({ workflow, isOpen, onClose, onSave, isSaving }:
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSaving ? (
<>

View File

@@ -10,7 +10,7 @@ interface EmptyStateProps {
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn('flex-1 flex items-center justify-center p-6', className)}>
<div className={cn('h-full flex items-center justify-center p-6', className)}>
<div className="text-center max-w-sm">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400">
{icon}