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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user