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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user