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

@@ -1,3 +1,7 @@
/* ZCLAW Desktop App - Minimal Legacy Styles */
/* Most styling is handled by Tailwind CSS and index.css design system */
/* Vite Logo Animation - Keep for any Vite default pages */
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
@@ -5,22 +9,8 @@
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
/* Container utilities */
.container {
margin: 0;
padding-top: 10vh;
@@ -46,71 +36,12 @@
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
/* Typography */
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
/* Greet input utility */
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}

View File

@@ -55,7 +55,7 @@ function App() {
const { connect, hands, approveHand, loadHands } = useGatewayStore();
const { activeTeam, setActiveTeam, teams } = useTeamStore();
const { setCurrentAgent } = useChatStore();
const { setCurrentAgent, newConversation } = useChatStore();
const { isNeeded: onboardingNeeded, isLoading: onboardingLoading, markCompleted } = useOnboarding();
useEffect(() => {
@@ -190,6 +190,12 @@ function App() {
setMainContentView(view);
};
// 处理新对话
const handleNewChat = () => {
newConversation();
setMainContentView('chat');
};
const handleSelectTeam = (teamId: string) => {
const team = teams.find(t => t.id === teamId);
if (team) {
@@ -233,6 +239,7 @@ function App() {
onMainViewChange={handleMainViewChange}
selectedTeamId={selectedTeamId}
onSelectTeam={handleSelectTeam}
onNewChat={handleNewChat}
/>
{/* 主内容区 */}
@@ -252,7 +259,7 @@ function App() {
animate="animate"
exit="exit"
transition={defaultTransition}
className="flex-1 overflow-hidden relative"
className="flex-1 overflow-hidden relative flex flex-col"
>
{mainContentView === 'automation' ? (
<motion.div

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}

View File

@@ -1,10 +1,14 @@
@import "tailwindcss";
:root {
/* Brand Colors */
--color-primary: #f97316;
--color-primary-hover: #ea580c;
--color-primary-light: #fff7ed;
/* Brand Colors - 中性灰色系 */
--color-primary: #374151; /* gray-700 */
--color-primary-hover: #1f2937; /* gray-800 */
--color-primary-light: #f3f4f6; /* gray-100 */
/* Accent Color - 仅用于重要强调 */
--color-accent: #f97316; /* orange-500 */
--color-accent-hover: #ea580c; /* orange-600 */
/* Semantic Colors */
--color-success: #22c55e;
@@ -77,7 +81,7 @@ body {
}
.agent-avatar {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
background: #4b5563; /* gray-600 */
}
.chat-bubble-assistant {
@@ -88,7 +92,7 @@ body {
}
.chat-bubble-user {
background: #f97316;
background: #374151; /* gray-700 */
color: white;
border-radius: 12px;
border-bottom-right-radius: 4px;

View File

@@ -66,8 +66,8 @@ const REQUIRED_FIELDS: Array<{ path: string; description: string }> = [
const DEFAULT_CONFIG: Partial<OpenFangConfig> = {
server: {
host: '127.0.0.1',
port: 50051,
websocket_port: 50051,
port: 4200,
websocket_port: 4200,
websocket_path: '/ws',
api_version: 'v1',
},

View File

@@ -6,9 +6,9 @@
* Supports Ed25519 device authentication + JWT.
*
* OpenFang Configuration:
* - Port: 50051
* - Port: 4200 (default from runtime-manifest.json)
* - WebSocket path: /ws
* - REST API: http://127.0.0.1:50051/api/*
* - REST API: http://127.0.0.1:4200/api/*
* - Config format: TOML
*
* Security:
@@ -62,7 +62,7 @@ function isLocalhost(url: string): boolean {
}
}
// OpenFang endpoints (actual port is 50051, not 4200)
// OpenFang endpoints (port 50051 - actual running port)
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
@@ -499,8 +499,8 @@ export class GatewayClient {
return Promise.resolve();
}
// Check if URL is for OpenFang (port 50051) - use REST mode
if (this.url.includes(':50051')) {
// Check if URL is for OpenFang (port 4200 or 50051) - use REST mode
if (this.url.includes(':4200') || this.url.includes(':50051')) {
return this.connectRest();
}
@@ -1153,6 +1153,12 @@ export class GatewayClient {
home_dir?: string;
default_model?: { model?: string; provider?: string };
}>('/api/config');
// 从 localStorage 读取前端特定配置
const storedTheme = localStorage.getItem('zclaw-theme') as 'light' | 'dark' | null;
const storedAutoStart = localStorage.getItem('zclaw-autoStart');
const storedShowToolCalls = localStorage.getItem('zclaw-showToolCalls');
// Map OpenFang config to frontend expected format
return {
quickConfig: {
@@ -1166,8 +1172,9 @@ export class GatewayClient {
gatewayUrl: this.getRestBaseUrl(),
defaultModel: config.default_model?.model,
defaultProvider: config.default_model?.provider,
theme: 'dark',
showToolCalls: true,
theme: storedTheme || 'light',
autoStart: storedAutoStart === 'true',
showToolCalls: storedShowToolCalls !== 'false',
autoSaveContext: true,
fileWatching: true,
privacyOptIn: false,
@@ -1182,6 +1189,17 @@ export class GatewayClient {
}
}
async saveQuickConfig(config: Record<string, any>): Promise<any> {
// 保存前端特定配置到 localStorage
if (config.theme !== undefined) {
localStorage.setItem('zclaw-theme', config.theme);
}
if (config.autoStart !== undefined) {
localStorage.setItem('zclaw-autoStart', String(config.autoStart));
}
if (config.showToolCalls !== undefined) {
localStorage.setItem('zclaw-showToolCalls', String(config.showToolCalls));
}
// Use /api/config endpoint for saving config
// Map frontend config back to OpenFang format
const openfangConfig = {

View File

@@ -104,6 +104,8 @@ interface QuickConfig {
personality?: string;
communicationStyle?: string;
notes?: string;
// 启用的 Provider 列表
enabledProviders?: string[];
}
interface WorkspaceInfo {
@@ -779,6 +781,8 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
get().loadWorkflows(),
get().loadTriggers(),
get().loadSecurityStatus(),
// Load available models
get().loadModels(),
]);
await get().loadChannels();
} catch (err: unknown) {
@@ -852,8 +856,64 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
loadUsageStats: async () => {
try {
const stats = await get().client.getUsageStats();
set({ usageStats: stats });
} catch { /* ignore */ }
// 如果 API 返回了有效数据,使用它
if (stats && (stats.totalMessages > 0 || stats.totalTokens > 0 || Object.keys(stats.byModel || {}).length > 0)) {
set({ usageStats: stats });
return;
}
} catch { /* ignore API error, fallback to local */ }
// Fallback: 从本地聊天存储计算统计数据
try {
const stored = localStorage.getItem('zclaw-chat-storage');
if (!stored) {
set({ usageStats: { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} } });
return;
}
const parsed = JSON.parse(stored);
// 处理 persist 中间件格式
const state = parsed?.state || parsed;
const conversations = state?.conversations || [];
// 计算统计数据
const usageStats: UsageStats = {
totalSessions: conversations.length,
totalMessages: 0,
totalTokens: 0,
byModel: {},
};
for (const conv of conversations) {
const messages = conv.messages || [];
usageStats.totalMessages += messages.length;
// 估算 token 数量 (粗略估算: 中文约 1.5 字符/token, 英文约 4 字符/token)
for (const msg of messages) {
const content = msg.content || '';
// 简单估算: 每个字符约 0.3 token (混合中英文的平均值)
const estimatedTokens = Math.ceil(content.length * 0.3);
usageStats.totalTokens += estimatedTokens;
// 按模型分组 (使用 currentModel 或默认)
const model = state.currentModel || 'default';
if (!usageStats.byModel[model]) {
usageStats.byModel[model] = { messages: 0, inputTokens: 0, outputTokens: 0 };
}
usageStats.byModel[model].messages++;
if (msg.role === 'user') {
usageStats.byModel[model].inputTokens += estimatedTokens;
} else {
usageStats.byModel[model].outputTokens += estimatedTokens;
}
}
}
set({ usageStats });
} catch (error) {
console.error('[GatewayStore] Failed to calculate local usage stats:', error);
set({ usageStats: { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} } });
}
},
loadPluginStatus: async () => {
@@ -1191,9 +1251,15 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
// === OpenFang Actions ===
loadHands: async () => {
const client = get().client;
if (!client) {
console.warn('[GatewayStore] No client available, skipping loadHands');
return;
}
set({ isLoading: true });
try {
const result = await get().client.listHands();
const result = await client.listHands();
// Map API response to Hand interface
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const;
const hands: Hand[] = (result?.hands || []).map(h => {
@@ -1213,8 +1279,10 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
};
});
set({ hands, isLoading: false });
} catch {
set({ isLoading: false });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.warn('[GatewayStore] Failed to load hands:', errorMsg);
set({ hands: [], isLoading: false });
/* ignore if hands API not available */
}
},
@@ -1330,12 +1398,20 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
},
loadWorkflows: async () => {
const client = get().client;
if (!client) {
console.warn('[GatewayStore] No client available, skipping loadWorkflows');
return;
}
set({ isLoading: true });
try {
const result = await get().client.listWorkflows();
const result = await client.listWorkflows();
set({ workflows: result?.workflows || [], isLoading: false });
} catch {
set({ isLoading: false });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.warn('[GatewayStore] Failed to load workflows:', errorMsg);
set({ workflows: [], isLoading: false });
/* ignore if workflows API not available */
}
},
@@ -1681,7 +1757,28 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
try {
set({ modelsLoading: true, modelsError: null });
const result = await get().client.listModels();
const models: GatewayModelChoice[] = result?.models || [];
const rawModels: GatewayModelChoice[] = result?.models || [];
// 获取用户启用的 provider 列表
const enabledProviders = get().quickConfig.enabledProviders as string[] | undefined;
// 去重:基于 id 去重,保留第一个出现的
const seen = new Set<string>();
const models: GatewayModelChoice[] = rawModels.filter(model => {
if (seen.has(model.id)) {
return false;
}
seen.add(model.id);
// 如果用户配置了 enabledProviders只显示启用的 provider 的模型
if (enabledProviders && enabledProviders.length > 0) {
// 从模型 ID 中提取 provider格式provider/model-id
const provider = model.id.split('/')[0];
return enabledProviders.includes(provider);
}
return true;
});
set({ models, modelsLoading: false });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load models';
@@ -1712,7 +1809,9 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
};
});
// Dev-only: Expose store to window for E2E testing
if (import.meta.env.DEV && typeof window !== 'undefined') {
(window as any).__ZCLAW_STORES__ = (window as any).__ZCLAW_STORES__ || {};
(window as any).__ZCLAW_STORES__.gateway = useGatewayStore;
}

View File

@@ -206,11 +206,17 @@ export const useHandStore = create<HandStore>((set, get) => ({
loadHands: async () => {
const client = get().client;
if (!client) return;
console.log('[HandStore] loadHands called, client:', !!client);
if (!client) {
console.warn('[HandStore] No client available, skipping loadHands');
return;
}
set({ isLoading: true });
try {
console.log('[HandStore] Calling client.listHands()...');
const result = await client.listHands();
console.log('[HandStore] listHands result:', result);
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const;
const hands: Hand[] = (result?.hands || []).map((h: Record<string, unknown>) => {
const status = validStatuses.includes(h.status as Hand['status'])
@@ -228,8 +234,10 @@ export const useHandStore = create<HandStore>((set, get) => ({
metricCount: (h.metric_count as number) || ((h.metrics as unknown[])?.length),
};
});
console.log('[HandStore] Mapped hands:', hands.length, 'items');
set({ hands, isLoading: false });
} catch {
} catch (err) {
console.error('[HandStore] loadHands error:', err);
set({ isLoading: false });
}
},

View File

@@ -139,11 +139,25 @@ export const useTeamStore = create<TeamStoreState>()(
set({ isLoading: true, error: null });
try {
// For now, load from localStorage until API is available
// Note: persist middleware stores data as { state: { teams: [...] }, version: ... }
const stored = localStorage.getItem('zclaw-teams');
const teams: Team[] = stored ? parseJsonOrDefault<Team[]>(stored, []) : [];
let teams: Team[] = [];
if (stored) {
const parsed = JSON.parse(stored);
// Handle persist middleware format
if (parsed?.state?.teams && Array.isArray(parsed.state.teams)) {
teams = parsed.state.teams;
} else if (Array.isArray(parsed)) {
// Direct array format (legacy)
teams = parsed;
}
}
set({ teams, isLoading: false });
} catch (error) {
set({ error: (error as Error).message, isLoading: false });
console.error('[TeamStore] Failed to load teams:', error);
set({ teams: [], isLoading: false });
}
},

View File

@@ -8,6 +8,7 @@
*/
import type { Hand, HandStatus, HandParameter } from './hands';
import { HAND_DEFINITIONS } from './hands';
import type { Workflow, WorkflowRunStatus } from './workflow';
// === Category Types ===
@@ -210,19 +211,39 @@ export function workflowStatusToAutomationStatus(status: WorkflowRunStatus): Aut
/**
* Adapts a Hand to an AutomationItem
* Merges name, description, and parameters from HAND_DEFINITIONS (中文优先)
*/
export function handToAutomationItem(hand: Hand): AutomationItem {
const category = HAND_CATEGORY_MAP[hand.id] || HAND_CATEGORY_MAP[hand.name.toLowerCase()] || 'productivity';
// Normalize hand id/name for matching (remove " Hand" suffix if present)
const normalizedId = hand.id.toLowerCase().replace(/\s*hand$/i, '');
const normalizedName = hand.name.toLowerCase().replace(/\s*hand$/i, '');
// Find matching definition by id or name to get Chinese content
const definition = HAND_DEFINITIONS.find(
d => d.id === normalizedId || d.id === normalizedName || d.id === hand.id.toLowerCase()
);
// Use Chinese name and description from definition, fall back to API data
const name = definition?.name || hand.name;
const description = definition?.description || hand.description;
// Try to get parameters from hand, or fall back to HAND_DEFINITIONS
let parameters = hand.parameters;
if ((!parameters || parameters.length === 0) && definition) {
parameters = definition.parameters;
}
return {
id: hand.id,
name: hand.name,
description: hand.description,
name,
description,
type: 'hand',
category,
status: handStatusToAutomationStatus(hand.status),
error: hand.error,
parameters: hand.parameters,
parameters,
requiresApproval: false, // Will be determined by execution result
lastRun: hand.lastRun ? {
runId: hand.lastRun,

View File

@@ -1,19 +1,20 @@
/**
* OpenFang Hands and Workflow Types
*
* OpenFang provides 7 autonomous capability packages (Hands):
* - Clip: Video processing
* - Lead: Sales lead management
* - Collector: Data collection
* - Predictor: Predictive analytics
* - Researcher: Deep research
* - Twitter: Twitter automation
* - Browser: Browser automation
* ZCLAW 提供 8 个自主能力包 (Hands)
* - Clip: 视频处理
* - Lead: 销售线索管理
* - Collector: 数据收集
* - Predictor: 预测分析
* - Researcher: 深度研究
* - Twitter: Twitter 自动化
* - Browser: 浏览器自动化
* - Trader: 交易分析
*/
export type HandStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
export type HandId = 'clip' | 'lead' | 'collector' | 'predictor' | 'researcher' | 'twitter' | 'browser';
export type HandId = 'clip' | 'lead' | 'collector' | 'predictor' | 'researcher' | 'twitter' | 'browser' | 'trader';
export type HandParameterType = 'text' | 'number' | 'select' | 'textarea' | 'boolean' | 'array' | 'object' | 'file';
@@ -92,111 +93,131 @@ export interface WorkflowExecutionResult {
completedAt: string;
}
// Hand definitions with metadata
// Hand definitions with metadata (中文化)
export const HAND_DEFINITIONS: Array<Omit<Hand, 'status' | 'lastRun' | 'lastResult' | 'error'>> = [
{
id: 'clip',
name: 'Clip',
description: 'Video processing and editing automation',
name: 'Clip 视频处理',
description: '将长视频转换为短视频片段,自动生成字幕和封面',
icon: 'Video',
parameters: [
{ name: 'inputPath', label: 'Input Path', type: 'text', required: true, placeholder: 'Video file or URL' },
{ name: 'outputFormat', label: 'Output Format', type: 'select', required: false, options: [
{ name: 'inputPath', label: '输入路径', type: 'text', required: true, placeholder: '视频文件或链接' },
{ name: 'outputFormat', label: '输出格式', type: 'select', required: false, options: [
{ value: 'mp4', label: 'MP4' },
{ value: 'webm', label: 'WebM' },
{ value: 'gif', label: 'GIF' },
], defaultValue: 'mp4' },
{ name: 'trimStart', label: 'Start Time', type: 'number', required: false, placeholder: 'Seconds' },
{ name: 'trimEnd', label: 'End Time', type: 'number', required: false, placeholder: 'Seconds' },
{ name: 'trimStart', label: '开始时间', type: 'number', required: false, placeholder: '' },
{ name: 'trimEnd', label: '结束时间', type: 'number', required: false, placeholder: '' },
],
},
{
id: 'lead',
name: 'Lead',
description: 'Sales lead generation and management',
name: 'Lead 线索发现',
description: '自动发现、丰富和交付合格的销售线索',
icon: 'UserPlus',
parameters: [
{ name: 'source', label: 'Data Source', type: 'select', required: true, options: [
{ name: 'source', label: '数据来源', type: 'select', required: true, options: [
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'crunchbase', label: 'Crunchbase' },
{ value: 'custom', label: 'Custom List' },
{ value: 'custom', label: '自定义列表' },
] },
{ name: 'query', label: 'Search Query', type: 'textarea', required: true, placeholder: 'Enter search criteria' },
{ name: 'maxResults', label: 'Max Results', type: 'number', required: false, defaultValue: 50 },
{ name: 'query', label: '搜索条件', type: 'textarea', required: true, placeholder: '输入搜索条件' },
{ name: 'maxResults', label: '最大结果数', type: 'number', required: false, defaultValue: 50 },
],
},
{
id: 'collector',
name: 'Collector',
description: 'Automated data collection and aggregation',
name: 'Collector 数据采集',
description: '自动收集和聚合数据,支持变更检测和知识图谱',
icon: 'Database',
parameters: [
{ name: 'targetUrl', label: 'Target URL', type: 'text', required: true, placeholder: 'URL to scrape' },
{ name: 'selector', label: 'CSS Selector', type: 'text', required: false, placeholder: 'Elements to extract' },
{ name: 'outputFormat', label: 'Output Format', type: 'select', required: false, options: [
{ name: 'targetUrl', label: '目标网址', type: 'text', required: true, placeholder: '要采集的网址' },
{ name: 'selector', label: 'CSS 选择器', type: 'text', required: false, placeholder: '要提取的元素' },
{ name: 'outputFormat', label: '输出格式', type: 'select', required: false, options: [
{ value: 'json', label: 'JSON' },
{ value: 'csv', label: 'CSV' },
{ value: 'xlsx', label: 'Excel' },
], defaultValue: 'json' },
{ name: 'pagination', label: 'Follow Pagination', type: 'boolean', required: false, defaultValue: false },
{ name: 'pagination', label: '跟踪分页', type: 'boolean', required: false, defaultValue: false },
],
},
{
id: 'predictor',
name: 'Predictor',
description: 'Predictive analytics and forecasting',
name: 'Predictor 预测分析',
description: '收集信号、构建推理链、进行校准预测并跟踪准确性',
icon: 'TrendingUp',
parameters: [
{ name: 'dataSource', label: 'Data Source', type: 'text', required: true, placeholder: 'Data file path or URL' },
{ name: 'model', label: 'Model Type', type: 'select', required: true, options: [
{ value: 'regression', label: 'Regression' },
{ value: 'classification', label: 'Classification' },
{ value: 'timeseries', label: 'Time Series' },
{ name: 'dataSource', label: '数据源', type: 'text', required: true, placeholder: '数据文件路径或链接' },
{ name: 'model', label: '模型类型', type: 'select', required: true, options: [
{ value: 'regression', label: '回归分析' },
{ value: 'classification', label: '分类预测' },
{ value: 'timeseries', label: '时间序列' },
] },
{ name: 'targetColumn', label: 'Target Column', type: 'text', required: true },
{ name: 'targetColumn', label: '目标列', type: 'text', required: true },
],
},
{
id: 'researcher',
name: 'Researcher',
description: 'Deep research and analysis automation',
name: 'Researcher 深度研究',
description: '进行详尽调查、交叉验证、事实核查和结构化报告',
icon: 'Search',
parameters: [
{ name: 'topic', label: 'Research Topic', type: 'textarea', required: true, placeholder: 'Enter research topic' },
{ name: 'depth', label: 'Research Depth', type: 'select', required: false, options: [
{ value: 'shallow', label: 'Quick Overview' },
{ value: 'medium', label: 'Standard Research' },
{ value: 'deep', label: 'Comprehensive Analysis' },
{ name: 'topic', label: '研究主题', type: 'textarea', required: true, placeholder: '输入研究主题' },
{ name: 'depth', label: '研究深度', type: 'select', required: false, options: [
{ value: 'shallow', label: '快速概览' },
{ value: 'medium', label: '标准研究' },
{ value: 'deep', label: '深度分析' },
], defaultValue: 'medium' },
{ name: 'sources', label: 'Max Sources', type: 'number', required: false, defaultValue: 10 },
{ name: 'sources', label: '最大来源数', type: 'number', required: false, defaultValue: 10 },
],
},
{
id: 'twitter',
name: 'Twitter',
description: 'Twitter/X automation and engagement',
name: 'Twitter 自动化',
description: 'Twitter/X 内容创作、定时发布、互动和效果跟踪',
icon: 'Twitter',
parameters: [
{ name: 'action', label: 'Action Type', type: 'select', required: true, options: [
{ value: 'post', label: 'Post Tweet' },
{ value: 'search', label: 'Search Tweets' },
{ value: 'analyze', label: 'Analyze Trends' },
{ value: 'engage', label: 'Engage (Like/Reply)' },
{ name: 'action', label: '操作类型', type: 'select', required: true, options: [
{ value: 'post', label: '发布推文' },
{ value: 'search', label: '搜索推文' },
{ value: 'analyze', label: '分析趋势' },
{ value: 'engage', label: '互动 (点赞/回复)' },
] },
{ name: 'content', label: 'Content', type: 'textarea', required: false, placeholder: 'Tweet content or search query' },
{ name: 'schedule', label: 'Schedule Time', type: 'text', required: false, placeholder: 'ISO datetime or "now"' },
{ name: 'content', label: '内容', type: 'textarea', required: false, placeholder: '推文内容或搜索关键词' },
{ name: 'schedule', label: '定时发布', type: 'text', required: false, placeholder: '时间或 "now"' },
],
},
{
id: 'browser',
name: 'Browser',
description: 'Browser automation and web interaction',
name: 'Browser 浏览器自动化',
description: '自动浏览网站、填写表单、点击按钮,完成多步骤网页任务',
icon: 'Globe',
parameters: [
{ name: 'url', label: 'Starting URL', type: 'text', required: true, placeholder: 'https://example.com' },
{ name: 'actions', label: 'Actions', type: 'textarea', required: true, placeholder: 'List of actions to perform' },
{ name: 'headless', label: 'Headless Mode', type: 'boolean', required: false, defaultValue: true },
{ name: 'timeout', label: 'Timeout (seconds)', type: 'number', required: false, defaultValue: 30 },
{ name: 'url', label: '起始网址', type: 'text', required: true, placeholder: 'https://example.com' },
{ name: 'actions', label: '操作步骤', type: 'textarea', required: true, placeholder: '要执行的操作列表' },
{ name: 'headless', label: '无头模式', type: 'boolean', required: false, defaultValue: true },
{ name: 'timeout', label: '超时时间 (秒)', type: 'number', required: false, defaultValue: 30 },
],
},
{
id: 'trader',
name: 'Trader 交易分析',
description: '多信号分析、多空推理、校准置信度评分、严格风险管理和投资组合分析',
icon: 'TrendingUp',
parameters: [
{ name: 'symbol', label: '交易标的', type: 'text', required: true, placeholder: '股票代码或加密货币' },
{ name: 'analysisType', label: '分析类型', type: 'select', required: false, options: [
{ value: 'technical', label: '技术分析' },
{ value: 'fundamental', label: '基本面分析' },
{ value: 'sentiment', label: '情绪分析' },
], defaultValue: 'technical' },
{ name: 'timeframe', label: '时间周期', type: 'select', required: false, options: [
{ value: '1h', label: '1小时' },
{ value: '4h', label: '4小时' },
{ value: '1d', label: '1天' },
{ value: '1w', label: '1周' },
], defaultValue: '1d' },
],
},
];