feat(automation): complete unified automation system redesign
Phase 4 completion: - Add ApprovalQueue component for managing pending approvals - Add ExecutionResult component for displaying hand/workflow results - Update Sidebar navigation to use unified AutomationPanel - Replace separate 'hands' and 'workflow' tabs with single 'automation' tab - Fix TypeScript type safety issues with unknown types in JSX expressions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
17
desktop/src-tauri/Cargo.lock
generated
@@ -735,6 +735,7 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"fantoccini",
|
||||
"futures",
|
||||
"keyring",
|
||||
"regex",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
@@ -2117,6 +2118,16 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -5913,6 +5924,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -35,3 +35,6 @@ base64 = "0.22"
|
||||
thiserror = "2"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# Secure storage (OS keyring/keychain)
|
||||
keyring = "3"
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ mod llm;
|
||||
// Browser automation module (Fantoccini-based Browser Hand)
|
||||
mod browser;
|
||||
|
||||
// Secure storage module for OS keyring/keychain
|
||||
mod secure_storage;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
@@ -1066,7 +1069,12 @@ pub fn run() {
|
||||
browser::commands::browser_element_screenshot,
|
||||
browser::commands::browser_get_source,
|
||||
browser::commands::browser_scrape_page,
|
||||
browser::commands::browser_fill_form
|
||||
browser::commands::browser_fill_form,
|
||||
// Secure storage commands (OS keyring/keychain)
|
||||
secure_storage::secure_store_set,
|
||||
secure_storage::secure_store_get,
|
||||
secure_storage::secure_store_delete,
|
||||
secure_storage::secure_store_is_available
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -5,19 +5,21 @@ import { Sidebar, MainViewType } from './components/Sidebar';
|
||||
import { ChatArea } from './components/ChatArea';
|
||||
import { RightPanel } from './components/RightPanel';
|
||||
import { SettingsLayout } from './components/Settings/SettingsLayout';
|
||||
import { HandTaskPanel } from './components/HandTaskPanel';
|
||||
import { SchedulerPanel } from './components/SchedulerPanel';
|
||||
import { AutomationPanel } from './components/Automation';
|
||||
import { TeamCollaborationView } from './components/TeamCollaborationView';
|
||||
import { TeamOrchestrator } from './components/TeamOrchestrator';
|
||||
import { SwarmDashboard } from './components/SwarmDashboard';
|
||||
import { SkillMarket } from './components/SkillMarket';
|
||||
import { AgentOnboardingWizard } from './components/AgentOnboardingWizard';
|
||||
import { HandApprovalModal } from './components/HandApprovalModal';
|
||||
import { TopBar } from './components/TopBar';
|
||||
import { DetailDrawer } from './components/DetailDrawer';
|
||||
import { useGatewayStore, type HandRun } from './store/gatewayStore';
|
||||
import { useTeamStore } from './store/teamStore';
|
||||
import { useChatStore } from './store/chatStore';
|
||||
import { getStoredGatewayToken } from './lib/gateway-client';
|
||||
import { pageVariants, defaultTransition, fadeInVariants } from './lib/animations';
|
||||
import { Bot, Users, Loader2 } from 'lucide-react';
|
||||
import { Users, Loader2, Settings } from 'lucide-react';
|
||||
import { EmptyState } from './components/ui';
|
||||
import { isTauriRuntime, getLocalGatewayStatus, startLocalGateway } from './lib/tauri-gateway';
|
||||
import { useOnboarding } from './lib/use-onboarding';
|
||||
@@ -40,15 +42,16 @@ function BootstrapScreen({ status }: { status: string }) {
|
||||
function App() {
|
||||
const [view, setView] = useState<View>('main');
|
||||
const [mainContentView, setMainContentView] = useState<MainViewType>('chat');
|
||||
const [selectedHandId, setSelectedHandId] = useState<string | undefined>(undefined);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(undefined);
|
||||
const [bootstrapping, setBootstrapping] = useState(true);
|
||||
const [bootstrapStatus, setBootstrapStatus] = useState('Initializing...');
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [showDetailDrawer, setShowDetailDrawer] = useState(false);
|
||||
|
||||
// Hand Approval state
|
||||
const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [teamViewMode, setTeamViewMode] = useState<'collaboration' | 'orchestrator'>('collaboration');
|
||||
|
||||
const { connect, hands, approveHand, loadHands } = useGatewayStore();
|
||||
const { activeTeam, setActiveTeam, teams } = useTeamStore();
|
||||
@@ -182,13 +185,9 @@ function App() {
|
||||
setShowOnboarding(false);
|
||||
};
|
||||
|
||||
// 当切换到非 hands 视图时清除选中的 Hand
|
||||
// 处理主视图切换
|
||||
const handleMainViewChange = (view: MainViewType) => {
|
||||
setMainContentView(view);
|
||||
if (view !== 'hands') {
|
||||
// 可选:清除选中的 Hand
|
||||
// setSelectedHandId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectTeam = (teamId: string) => {
|
||||
@@ -227,84 +226,120 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden text-gray-800 text-sm">
|
||||
<div className="h-screen flex overflow-hidden text-gray-800 text-sm bg-white dark:bg-gray-950">
|
||||
{/* 左侧边栏 */}
|
||||
<Sidebar
|
||||
onOpenSettings={() => setView('settings')}
|
||||
onMainViewChange={handleMainViewChange}
|
||||
selectedHandId={selectedHandId}
|
||||
onSelectHand={setSelectedHandId}
|
||||
selectedTeamId={selectedTeamId}
|
||||
onSelectTeam={handleSelectTeam}
|
||||
/>
|
||||
|
||||
{/* 中间区域 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={mainContentView}
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={defaultTransition}
|
||||
className="flex-1 flex flex-col bg-white relative overflow-hidden"
|
||||
>
|
||||
{mainContentView === 'hands' && selectedHandId ? (
|
||||
<HandTaskPanel
|
||||
handId={selectedHandId}
|
||||
onBack={() => setSelectedHandId(undefined)}
|
||||
/>
|
||||
) : mainContentView === 'hands' ? (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="Select a Hand"
|
||||
description="Choose an autonomous capability package from the list on the left to view its task list and execution results."
|
||||
/>
|
||||
) : mainContentView === 'workflow' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
<SchedulerPanel />
|
||||
</motion.div>
|
||||
) : mainContentView === 'team' ? (
|
||||
activeTeam ? (
|
||||
<TeamCollaborationView teamId={activeTeam.id} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Users className="w-8 h-8" />}
|
||||
title="Select or Create a Team"
|
||||
description="Choose a team from the list on the left, or click + to create a new multi-Agent collaboration team."
|
||||
/>
|
||||
)
|
||||
) : mainContentView === 'swarm' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="flex-1 overflow-hidden"
|
||||
>
|
||||
<SwarmDashboard />
|
||||
</motion.div>
|
||||
) : mainContentView === 'skills' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="flex-1 overflow-hidden"
|
||||
>
|
||||
<SkillMarket />
|
||||
</motion.div>
|
||||
) : (
|
||||
<ChatArea />
|
||||
)}
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 顶部工具栏 */}
|
||||
<TopBar
|
||||
title="ZCLAW"
|
||||
onOpenDetail={() => setShowDetailDrawer(true)}
|
||||
/>
|
||||
|
||||
{/* 右侧边栏 */}
|
||||
<RightPanel />
|
||||
{/* 内容区域 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={mainContentView}
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={defaultTransition}
|
||||
className="flex-1 overflow-hidden relative"
|
||||
>
|
||||
{mainContentView === 'automation' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
<AutomationPanel />
|
||||
</motion.div>
|
||||
) : mainContentView === 'team' ? (
|
||||
activeTeam ? (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Team View Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 px-4">
|
||||
<button
|
||||
onClick={() => setTeamViewMode('collaboration')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
teamViewMode === 'collaboration'
|
||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
协作视图
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTeamViewMode('orchestrator')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
teamViewMode === 'orchestrator'
|
||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
编排管理
|
||||
</button>
|
||||
</div>
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{teamViewMode === 'orchestrator' ? (
|
||||
<TeamOrchestrator isOpen={true} onClose={() => setTeamViewMode('collaboration')} />
|
||||
) : (
|
||||
<TeamCollaborationView teamId={activeTeam.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Users className="w-8 h-8" />}
|
||||
title="选择或创建团队"
|
||||
description="从左侧列表中选择一个团队,或点击 + 创建新的多 Agent 协作团队。"
|
||||
/>
|
||||
)
|
||||
) : mainContentView === 'swarm' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="h-full overflow-hidden"
|
||||
>
|
||||
<SwarmDashboard />
|
||||
</motion.div>
|
||||
) : mainContentView === 'skills' ? (
|
||||
<motion.div
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="h-full overflow-hidden"
|
||||
>
|
||||
<SkillMarket />
|
||||
</motion.div>
|
||||
) : (
|
||||
<ChatArea />
|
||||
)}
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 详情抽屉 - 按需显示 */}
|
||||
<DetailDrawer
|
||||
open={showDetailDrawer}
|
||||
onClose={() => setShowDetailDrawer(false)}
|
||||
title="详情"
|
||||
>
|
||||
<RightPanel />
|
||||
</DetailDrawer>
|
||||
|
||||
{/* Hand Approval Modal (global) */}
|
||||
<HandApprovalModal
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
X,
|
||||
Download,
|
||||
Clock,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { Button, EmptyState } from './ui';
|
||||
import { Button, EmptyState, Badge } from './ui';
|
||||
import { useActiveLearningStore } from '../store/activeLearningStore';
|
||||
import {
|
||||
type LearningEvent,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type LearningEventType,
|
||||
} from '../types/active-learning';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
|
||||
// === Constants ===
|
||||
|
||||
@@ -58,10 +60,12 @@ function EventItem({ event, onAcknowledge }: EventItemProps) {
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className={`p-3 rounded-lg border ${
|
||||
event.acknowledged
|
||||
? 'bg-gray-800/30 border-gray-700'
|
||||
: 'bg-blue-900/20 border-blue-700'
|
||||
? 'bg-gray-50 dark:bg-gray-800 border-gray-100 dark:border-gray-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -70,11 +74,11 @@ function EventItem({ event, onAcknowledge }: EventItemProps) {
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${typeInfo.color}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{timeAgo}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{timeAgo}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 truncate">{event.observation}</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">{event.observation}</p>
|
||||
{event.inferredPreference && (
|
||||
<p className="text-xs text-gray-500 mt-1">→ {event.inferredPreference}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">→ {event.inferredPreference}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -85,7 +89,7 @@ function EventItem({ event, onAcknowledge }: EventItemProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>置信度: {(event.confidence * 100).toFixed(0)}%</span>
|
||||
{event.appliedCount > 0 && (
|
||||
<span>• 应用 {event.appliedCount} 次</span>
|
||||
@@ -111,13 +115,15 @@ function SuggestionCard({ suggestion, onApply, onDismiss }: SuggestionCardProps)
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="p-4 bg-gradient-to-r from-amber-900/20 to-transparent rounded-lg border border-amber-700/50"
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="p-4 bg-gradient-to-r from-amber-50 to-transparent dark:from-amber-900/20 dark:to-transparent rounded-lg border border-amber-200 dark:border-amber-700/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<Lightbulb className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-200">{suggestion.suggestion}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-200">{suggestion.suggestion}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>置信度: {(suggestion.confidence * 100).toFixed(0)}%</span>
|
||||
{daysLeft > 0 && <span>• {daysLeft} 天后过期</span>}
|
||||
</div>
|
||||
@@ -204,63 +210,77 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
}, [agentId, clearEvents]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full bg-gray-900 ${className}`}>
|
||||
{/* 夨览栏 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-5 h-5 text-blue-400" />
|
||||
<h2 className="text-lg font-semibold text-white">主动学习</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* 启用开关和导出 */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Brain className="w-4 h-4 text-blue-500" />
|
||||
<span>主动学习</span>
|
||||
<Badge variant={config.enabled ? 'success' : 'default'} className="ml-1">
|
||||
{config.enabled ? '已启用' : '已禁用'}
|
||||
</Badge>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => setConfig({ enabled: e.target.checked })}
|
||||
className="rounded"
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleExport}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleExport} title="导出数据">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 统计概览 */}
|
||||
<div className="grid grid-cols-4 gap-2 p-3 bg-gray-800/30">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">{stats.totalEvents}</div>
|
||||
<div className="text-xs text-gray-500">学习事件</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-400">{stats.totalPatterns}</div>
|
||||
<div className="text-xs text-gray-500">学习模式</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-amber-400">{agentSuggestions.length}</div>
|
||||
<div className="text-xs text-gray-500">待处理建议</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">
|
||||
{(stats.avgConfidence * 100).toFixed(0)}%
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 p-3"
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1.5">
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
学习统计
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-500">{stats.totalEvents}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">事件</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-500">{stats.totalPatterns}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">模式</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-amber-500">{agentSuggestions.length}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">建议</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-purple-500">
|
||||
{(stats.avgConfidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">置信度</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">平均置信度</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex border-b border-gray-800">
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{(['suggestions', 'events', 'patterns'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'text-blue-400 border-b-2 border-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
? 'text-emerald-600 dark:text-emerald-400 border-b-2 border-emerald-500'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'suggestions' && '建议'}
|
||||
@@ -271,7 +291,7 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'suggestions' && (
|
||||
<motion.div
|
||||
@@ -283,9 +303,10 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
>
|
||||
{agentSuggestions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Lightbulb className="w-12 h-12" />}
|
||||
icon={<Lightbulb className="w-8 h-8" />}
|
||||
title="暂无学习建议"
|
||||
description="系统会根据您的反馈自动生成改进建议"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentSuggestions.map(suggestion => (
|
||||
@@ -310,9 +331,10 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
>
|
||||
{agentEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Clock className="w-12 h-12" />}
|
||||
icon={<Clock className="w-8 h-8" />}
|
||||
title="暂无学习事件"
|
||||
description="开始对话后,系统会自动记录学习事件"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentEvents.map(event => (
|
||||
@@ -336,32 +358,35 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
>
|
||||
{agentPatterns.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<TrendingUp className="w-12 h-12" />}
|
||||
icon={<TrendingUp className="w-8 h-8" />}
|
||||
title="暂无学习模式"
|
||||
description="积累更多反馈后,系统会识别出行为模式"
|
||||
className="py-4"
|
||||
/>
|
||||
) : (
|
||||
agentPatterns.map(pattern => {
|
||||
const typeInfo = PATTERN_TYPE_LABELS[pattern.type] || { label: pattern.type, icon: '📊' };
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={`${pattern.agentId}-${pattern.pattern}`}
|
||||
className="p-3 bg-gray-800/50 rounded-lg border border-gray-700"
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{typeInfo.icon}</span>
|
||||
<span className="text-sm font-medium text-white">{typeInfo.label}</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">{typeInfo.label}</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-700 text-gray-300">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{(pattern.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{pattern.description}</p>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{pattern.description}</p>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{pattern.examples.length} 个示例
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -371,13 +396,13 @@ export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-gray-800">
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
上次更新: {agentEvents[0] ? getTimeAgo(agentEvents[0].timestamp) : '无'}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="text-red-400">
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="text-red-500 hover:text-red-600">
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
清除数据
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
352
desktop/src/components/Automation/ApprovalQueue.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* ApprovalQueue - Approval Management Component
|
||||
*
|
||||
* Displays pending approvals for hand executions that require
|
||||
* human approval, with approve/reject actions.
|
||||
*
|
||||
* @module components/Automation/ApprovalQueue
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useHandStore } from '../../store/handStore';
|
||||
import type { Approval, ApprovalStatus } from '../../store/handStore';
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../ui/Toast';
|
||||
|
||||
// === Status Config ===
|
||||
|
||||
const STATUS_CONFIG: Record<ApprovalStatus, {
|
||||
label: string;
|
||||
className: string;
|
||||
icon: typeof CheckCircle;
|
||||
}> = {
|
||||
pending: {
|
||||
label: '待处理',
|
||||
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
icon: Clock,
|
||||
},
|
||||
approved: {
|
||||
label: '已批准',
|
||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
rejected: {
|
||||
label: '已拒绝',
|
||||
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
icon: XCircle,
|
||||
},
|
||||
expired: {
|
||||
label: '已过期',
|
||||
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
};
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface ApprovalQueueProps {
|
||||
showFilters?: boolean;
|
||||
maxHeight?: string;
|
||||
onApprove?: (approval: Approval) => void;
|
||||
onReject?: (approval: Approval) => void;
|
||||
}
|
||||
|
||||
// === Approval Card Component ===
|
||||
|
||||
interface ApprovalCardProps {
|
||||
approval: Approval;
|
||||
onApprove: () => Promise<void>;
|
||||
onReject: (reason: string) => Promise<void>;
|
||||
isProcessing: boolean;
|
||||
}
|
||||
|
||||
function ApprovalCard({ approval, onApprove, onReject, isProcessing }: ApprovalCardProps) {
|
||||
const [showRejectInput, setShowRejectInput] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const StatusIcon = STATUS_CONFIG[approval.status].icon;
|
||||
|
||||
const handleReject = useCallback(async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
setShowRejectInput(true);
|
||||
return;
|
||||
}
|
||||
await onReject(rejectReason);
|
||||
setShowRejectInput(false);
|
||||
setRejectReason('');
|
||||
}, [rejectReason, onReject]);
|
||||
|
||||
const timeAgo = useCallback((dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return '刚刚';
|
||||
if (diffMins < 60) return `${diffMins} 分钟前`;
|
||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
||||
return `${diffDays} 天前`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_CONFIG[approval.status].className}`}>
|
||||
<StatusIcon className="w-3 h-3 inline mr-1" />
|
||||
{STATUS_CONFIG[approval.status].label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{timeAgo(approval.requestedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||
{approval.handName}
|
||||
</h4>
|
||||
{approval.reason && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{approval.reason}</p>
|
||||
)}
|
||||
{approval.action && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
操作: {approval.action}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Params Preview */}
|
||||
{approval.params && Object.keys(approval.params).length > 0 && (
|
||||
<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-900 rounded text-xs">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-1">参数:</p>
|
||||
<pre className="text-gray-700 dark:text-gray-300 overflow-x-auto">
|
||||
{JSON.stringify(approval.params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Input */}
|
||||
{showRejectInput && (
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="请输入拒绝原因..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{approval.status === 'pending' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onApprove}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
批准
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response Info */}
|
||||
{approval.status !== 'pending' && approval.respondedAt && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{approval.respondedBy && `由 ${approval.respondedBy} `}
|
||||
{STATUS_CONFIG[approval.status].label}
|
||||
{approval.responseReason && ` - ${approval.responseReason}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function ApprovalQueue({
|
||||
showFilters = true,
|
||||
maxHeight = '400px',
|
||||
onApprove,
|
||||
onReject,
|
||||
}: ApprovalQueueProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Store state
|
||||
const approvals = useHandStore(s => s.approvals);
|
||||
const loadApprovals = useHandStore(s => s.loadApprovals);
|
||||
const respondToApproval = useHandStore(s => s.respondToApproval);
|
||||
const isLoading = useHandStore(s => s.isLoading);
|
||||
|
||||
// Local state
|
||||
const [statusFilter, setStatusFilter] = useState<ApprovalStatus | 'all'>('pending');
|
||||
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load approvals on mount
|
||||
useEffect(() => {
|
||||
loadApprovals(statusFilter === 'all' ? undefined : statusFilter);
|
||||
}, [loadApprovals, statusFilter]);
|
||||
|
||||
// Handle approve
|
||||
const handleApprove = useCallback(async (approval: Approval) => {
|
||||
setProcessingIds(prev => new Set(prev).add(approval.id));
|
||||
try {
|
||||
await respondToApproval(approval.id, true);
|
||||
toast(`已批准: ${approval.handName}`, 'success');
|
||||
onApprove?.(approval);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
toast(`批准失败: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
setProcessingIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(approval.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [respondToApproval, toast, onApprove]);
|
||||
|
||||
// Handle reject
|
||||
const handleReject = useCallback(async (approval: Approval, reason: string) => {
|
||||
setProcessingIds(prev => new Set(prev).add(approval.id));
|
||||
try {
|
||||
await respondToApproval(approval.id, false, reason);
|
||||
toast(`已拒绝: ${approval.handName}`, 'success');
|
||||
onReject?.(approval);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
toast(`拒绝失败: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
setProcessingIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(approval.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [respondToApproval, toast, onReject]);
|
||||
|
||||
// Filter approvals
|
||||
const filteredApprovals = statusFilter === 'all'
|
||||
? approvals
|
||||
: approvals.filter(a => a.status === statusFilter);
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
pending: approvals.filter(a => a.status === 'pending').length,
|
||||
approved: approvals.filter(a => a.status === 'approved').length,
|
||||
rejected: approvals.filter(a => a.status === 'rejected').length,
|
||||
expired: approvals.filter(a => a.status === 'expired').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-orange-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
审批队列
|
||||
</h2>
|
||||
{stats.pending > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 rounded-full">
|
||||
{stats.pending} 待处理
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadApprovals(statusFilter === 'all' ? undefined : statusFilter)}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||
{[
|
||||
{ value: 'pending', label: '待处理', count: stats.pending },
|
||||
{ value: 'approved', label: '已批准', count: stats.approved },
|
||||
{ value: 'rejected', label: '已拒绝', count: stats.rejected },
|
||||
{ value: 'all', label: '全部', count: approvals.length },
|
||||
].map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setStatusFilter(option.value as ApprovalStatus | 'all')}
|
||||
className={`flex items-center gap-1 px-3 py-1 text-sm rounded-full whitespace-nowrap transition-colors ${
|
||||
statusFilter === option.value
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
<span className={`text-xs ${statusFilter === option.value ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
({option.count})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ maxHeight }}>
|
||||
{isLoading && approvals.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : filteredApprovals.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
<Clock className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{statusFilter === 'pending' ? '暂无待处理的审批' : '暂无审批记录'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredApprovals.map(approval => (
|
||||
<ApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
onApprove={() => handleApprove(approval)}
|
||||
onReject={(reason) => handleReject(approval, reason)}
|
||||
isProcessing={processingIds.has(approval.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApprovalQueue;
|
||||
395
desktop/src/components/Automation/ExecutionResult.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* ExecutionResult - Execution Result Display Component
|
||||
*
|
||||
* Displays the result of hand or workflow executions with
|
||||
* status, output, and error information.
|
||||
*
|
||||
* @module components/Automation/ExecutionResult
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { RunInfo } from '../../types/automation';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Code,
|
||||
Image,
|
||||
FileSpreadsheet,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../ui/Toast';
|
||||
|
||||
// === Status Config ===
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
completed: {
|
||||
label: '完成',
|
||||
icon: CheckCircle,
|
||||
className: 'text-green-500',
|
||||
bgClass: 'bg-green-50 dark:bg-green-900/20',
|
||||
},
|
||||
failed: {
|
||||
label: '失败',
|
||||
icon: XCircle,
|
||||
className: 'text-red-500',
|
||||
bgClass: 'bg-red-50 dark:bg-red-900/20',
|
||||
},
|
||||
running: {
|
||||
label: '运行中',
|
||||
icon: RefreshCw,
|
||||
className: 'text-blue-500 animate-spin',
|
||||
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
},
|
||||
needs_approval: {
|
||||
label: '待审批',
|
||||
icon: AlertTriangle,
|
||||
className: 'text-yellow-500',
|
||||
bgClass: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
},
|
||||
cancelled: {
|
||||
label: '已取消',
|
||||
icon: XCircle,
|
||||
className: 'text-gray-500',
|
||||
bgClass: 'bg-gray-50 dark:bg-gray-900/20',
|
||||
},
|
||||
};
|
||||
|
||||
// === Component Props ===
|
||||
|
||||
interface ExecutionResultProps {
|
||||
run: RunInfo;
|
||||
itemType: 'hand' | 'workflow';
|
||||
itemName: string;
|
||||
onRerun?: () => void;
|
||||
onViewDetails?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function formatDuration(startedAt: string, completedAt?: string): string {
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
const diffMs = end - start;
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function detectOutputType(output: unknown): 'text' | 'json' | 'markdown' | 'code' | 'image' | 'data' {
|
||||
if (!output) return 'text';
|
||||
|
||||
if (typeof output === 'string') {
|
||||
// Check for image URL
|
||||
if (output.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i)) {
|
||||
return 'image';
|
||||
}
|
||||
// Check for markdown
|
||||
if (output.includes('#') || output.includes('**') || output.includes('```')) {
|
||||
return 'markdown';
|
||||
}
|
||||
// Check for code
|
||||
if (output.includes('function ') || output.includes('import ') || output.includes('class ')) {
|
||||
return 'code';
|
||||
}
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
JSON.parse(output);
|
||||
return 'json';
|
||||
} catch {
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
// Object/array types
|
||||
if (typeof output === 'object') {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function formatOutput(output: unknown, type: string): string {
|
||||
if (!output) return '无输出';
|
||||
|
||||
if (type === 'json') {
|
||||
try {
|
||||
return JSON.stringify(output, null, 2);
|
||||
} catch {
|
||||
return String(output);
|
||||
}
|
||||
}
|
||||
|
||||
return String(output);
|
||||
}
|
||||
|
||||
// === Output Viewer Component ===
|
||||
|
||||
interface OutputViewerProps {
|
||||
output: unknown;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function OutputViewer({ output, type }: OutputViewerProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = formatOutput(output, type);
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast('已复制到剪贴板', 'success');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [output, type, toast]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const text = formatOutput(output, type);
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `output-${Date.now()}.${type === 'json' ? 'json' : 'txt'}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [output, type]);
|
||||
|
||||
// Image preview
|
||||
if (type === 'image' && typeof output === 'string') {
|
||||
return (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={output}
|
||||
alt="Output"
|
||||
className="max-w-full rounded-lg"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={() => window.open(output, '_blank')}
|
||||
className="p-1.5 bg-black/50 rounded hover:bg-black/70 text-white"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Text/JSON/Code output
|
||||
const content = formatOutput(output, type);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre className="p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm text-gray-100 overflow-x-auto max-h-64 overflow-y-auto">
|
||||
{content}
|
||||
</pre>
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
|
||||
title="复制"
|
||||
>
|
||||
{copied ? <CheckCircle className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function ExecutionResult({
|
||||
run,
|
||||
itemType,
|
||||
itemName,
|
||||
onRerun,
|
||||
onViewDetails,
|
||||
compact = false,
|
||||
}: ExecutionResultProps) {
|
||||
const [expanded, setExpanded] = useState(!compact);
|
||||
|
||||
const statusConfig = STATUS_CONFIG[run.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.completed;
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
// Safely extract error message as string
|
||||
const getErrorMessage = (): string | null => {
|
||||
if (typeof run.error === 'string' && run.error.length > 0) {
|
||||
return run.error;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const errorMessage = getErrorMessage();
|
||||
|
||||
const outputType = useMemo(() => detectOutputType(run.output), [run.output]);
|
||||
const duration = useMemo(() => {
|
||||
if (run.duration) return `${run.duration}s`;
|
||||
if (run.completedAt && run.startedAt) {
|
||||
return formatDuration(run.startedAt, run.completedAt);
|
||||
}
|
||||
return null;
|
||||
}, [run.duration, run.startedAt, run.completedAt]);
|
||||
|
||||
// Compact mode
|
||||
if (compact && !expanded) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg ${statusConfig.bgClass} cursor-pointer`}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<StatusIcon className={`w-5 h-5 ${statusConfig.className}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{itemName}
|
||||
</span>
|
||||
<span className={`text-xs ${statusConfig.className}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
{duration && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
耗时: {duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${statusConfig.bgClass} border-gray-200 dark:border-gray-700 overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-pointer"
|
||||
onClick={compact ? () => setExpanded(false) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusIcon className={`w-5 h-5 ${statusConfig.className}`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{itemName}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusConfig.className} ${statusConfig.bgClass}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{itemType === 'hand' ? 'Hand' : '工作流'}
|
||||
</span>
|
||||
</div>
|
||||
{run.runId && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Run ID: {run.runId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{duration && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
耗时: {duration}
|
||||
</span>
|
||||
)}
|
||||
{compact && (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Error */}
|
||||
{(() => {
|
||||
if (!errorMessage) return null;
|
||||
return (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">错误信息</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-300">{errorMessage}</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Output */}
|
||||
{run.output !== undefined && run.output !== null && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">输出结果</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
{outputType === 'json' && <Code className="w-3 h-3" />}
|
||||
{outputType === 'markdown' && <FileText className="w-3 h-3" />}
|
||||
{outputType === 'image' && <Image className="w-3 h-3" />}
|
||||
{outputType === 'data' && <FileSpreadsheet className="w-3 h-3" />}
|
||||
{outputType.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<OutputViewer output={run.output} type={outputType} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
开始: {new Date(run.startedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
{run.completedAt && (
|
||||
<span>
|
||||
完成: {new Date(run.completedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{onRerun && (
|
||||
<button
|
||||
onClick={onRerun}
|
||||
className="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
重新执行
|
||||
</button>
|
||||
)}
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
查看详情
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionResult;
|
||||
@@ -11,6 +11,8 @@ export { AutomationCard } from './AutomationCard';
|
||||
export { AutomationFilters } from './AutomationFilters';
|
||||
export { BatchActionBar } from './BatchActionBar';
|
||||
export { ScheduleEditor } from './ScheduleEditor';
|
||||
export { ApprovalQueue } from './ApprovalQueue';
|
||||
export { ExecutionResult } from './ExecutionResult';
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare } f
|
||||
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'];
|
||||
|
||||
@@ -21,6 +22,7 @@ export function ChatArea() {
|
||||
const [showModelPicker, setShowModelPicker] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Get current clone for first conversation prompt
|
||||
const currentClone = useMemo(() => {
|
||||
@@ -74,8 +76,21 @@ export function ChatArea() {
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
// Navigate to a specific message by ID
|
||||
const handleNavigateToMessage = useCallback((messageId: string) => {
|
||||
const messageEl = messageRefs.current.get(messageId);
|
||||
if (messageEl && scrollRef.current) {
|
||||
messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Add highlight effect
|
||||
messageEl.classList.add('ring-2', 'ring-orange-400', 'ring-offset-2');
|
||||
setTimeout(() => {
|
||||
messageEl.classList.remove('ring-2', 'ring-orange-400', 'ring-offset-2');
|
||||
}, 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -93,6 +108,9 @@ export function ChatArea() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -138,6 +156,7 @@ export function ChatArea() {
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
ref={(el) => { if (el) messageRefs.current.set(message.id, el); }}
|
||||
variants={listItemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@@ -211,10 +230,10 @@ export function ChatArea() {
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={isStreaming || !input.trim() || !connected}
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
<ArrowUp className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +242,7 @@ export function ChatArea() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,6 +351,9 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
// 思考中状态:streaming 且内容为空时显示思考指示器
|
||||
const isThinking = message.streaming && !message.content;
|
||||
|
||||
return (
|
||||
<div className={`flex gap-4 ${isUser ? 'justify-end' : ''}`}>
|
||||
<div
|
||||
@@ -340,17 +362,29 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
{isUser ? '用' : 'Z'}
|
||||
</div>
|
||||
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'}`}>
|
||||
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700 dark:text-gray-200'}`}>
|
||||
{message.content
|
||||
? (isUser ? message.content : renderMarkdown(message.content))
|
||||
: (message.streaming ? '' : '...')}
|
||||
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
|
||||
{isThinking ? (
|
||||
// 思考中指示器
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
<div className="flex gap-1">
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
<span className="text-sm">思考中...</span>
|
||||
</div>
|
||||
{message.error && (
|
||||
<p className="text-xs text-red-500 mt-2">{message.error}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'}`}>
|
||||
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700 dark:text-gray-200'}`}>
|
||||
{message.content
|
||||
? (isUser ? message.content : renderMarkdown(message.content))
|
||||
: '...'}
|
||||
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
|
||||
</div>
|
||||
{message.error && (
|
||||
<p className="text-xs text-red-500 mt-2">{message.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
346
desktop/src/components/CodeSnippetPanel.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* CodeSnippetPanel - 代码片段快速浏览面板
|
||||
*
|
||||
* 功能:
|
||||
* - 搜索过滤代码片段
|
||||
* - 按语言筛选
|
||||
* - 展开/折叠查看完整代码
|
||||
* - 一键复制
|
||||
* - 下载为文件
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Search, Copy, Download, ChevronDown, ChevronUp,
|
||||
FileCode, X, Check, Code
|
||||
} from 'lucide-react';
|
||||
import { Button, EmptyState } from './ui';
|
||||
import type { CodeBlock } from '../store/chatStore';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface CodeSnippet {
|
||||
id: string;
|
||||
block: CodeBlock;
|
||||
messageIndex: number;
|
||||
}
|
||||
|
||||
interface CodeSnippetPanelProps {
|
||||
snippets: CodeSnippet[];
|
||||
}
|
||||
|
||||
// === Language Colors ===
|
||||
|
||||
const LANGUAGE_COLORS: Record<string, string> = {
|
||||
python: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
javascript: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
typescript: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
rust: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
go: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
|
||||
java: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
cpp: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
c: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
html: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
css: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
sql: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
bash: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
shell: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
json: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
yaml: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
markdown: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
text: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
};
|
||||
|
||||
function getLanguageColor(lang: string): string {
|
||||
return LANGUAGE_COLORS[lang.toLowerCase()] || LANGUAGE_COLORS.text;
|
||||
}
|
||||
|
||||
function getFileExtension(lang?: string): string {
|
||||
const extensions: Record<string, string> = {
|
||||
python: 'py',
|
||||
javascript: 'js',
|
||||
typescript: 'ts',
|
||||
rust: 'rs',
|
||||
go: 'go',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
c: 'c',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
sql: 'sql',
|
||||
bash: 'sh',
|
||||
shell: 'sh',
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
markdown: 'md',
|
||||
};
|
||||
return extensions[lang?.toLowerCase() || ''] || 'txt';
|
||||
}
|
||||
|
||||
// === Snippet Card Component ===
|
||||
|
||||
interface SnippetCardProps {
|
||||
snippet: CodeSnippet;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function SnippetCard({ snippet, isExpanded, onToggle }: SnippetCardProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { block, messageIndex } = snippet;
|
||||
|
||||
const handleCopy = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [block.content]);
|
||||
|
||||
const handleDownload = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const filename = block.filename || `snippet_${messageIndex + 1}.${getFileExtension(block.language)}`;
|
||||
const blob = new Blob([block.content || ''], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [block.content, block.filename, block.language, messageIndex]);
|
||||
|
||||
const lineCount = (block.content || '').split('\n').length;
|
||||
const charCount = (block.content || '').length;
|
||||
const previewLines = (block.content || '').split('\n').slice(0, 2).join('\n');
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileCode className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200 truncate text-sm">
|
||||
{block.filename || `代码片段 #${messageIndex + 1}`}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getLanguageColor(block.language || 'text')}`}>
|
||||
{block.language || 'text'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{lineCount} 行 · {charCount > 1024 ? `${(charCount / 1024).toFixed(1)} KB` : `${charCount} 字符`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 opacity-60 hover:opacity-100"
|
||||
title="复制代码"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 opacity-60 hover:opacity-100"
|
||||
title="下载文件"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400 ml-1" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400 ml-1" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Preview / Full Content */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded ? (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-3">
|
||||
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
||||
{block.content}
|
||||
</pre>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="px-3 pb-3">
|
||||
<pre className="text-xs font-mono text-gray-500 dark:text-gray-400 overflow-hidden whitespace-pre-wrap break-all line-clamp-2">
|
||||
{previewLines}
|
||||
{(block.content || '').split('\n').length > 2 && '...'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function CodeSnippetPanel({ snippets }: CodeSnippetPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Get unique languages with counts
|
||||
const languages = useMemo(() => {
|
||||
const langMap = new Map<string, number>();
|
||||
snippets.forEach(s => {
|
||||
const lang = s.block.language?.toLowerCase() || 'text';
|
||||
langMap.set(lang, (langMap.get(lang) || 0) + 1);
|
||||
});
|
||||
return Array.from(langMap.entries())
|
||||
.map(([lang, count]) => ({ lang, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [snippets]);
|
||||
|
||||
// Filter snippets
|
||||
const filteredSnippets = useMemo(() => {
|
||||
return snippets.filter(snippet => {
|
||||
const block = snippet.block;
|
||||
|
||||
// Language filter
|
||||
if (selectedLanguage && (block.language?.toLowerCase() || 'text') !== selectedLanguage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesFilename = block.filename?.toLowerCase().includes(query);
|
||||
const matchesContent = block.content?.toLowerCase().includes(query);
|
||||
const matchesLanguage = block.language?.toLowerCase().includes(query);
|
||||
return matchesFilename || matchesContent || matchesLanguage;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [snippets, searchQuery, selectedLanguage]);
|
||||
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
setExpandedId(prev => prev === id ? null : id);
|
||||
}, []);
|
||||
|
||||
if (snippets.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Code className="w-10 h-10" />}
|
||||
title="暂无代码片段"
|
||||
description="对话中生成的代码会自动出现在这里"
|
||||
className="py-8"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
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:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language Filters */}
|
||||
{languages.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setSelectedLanguage(null)}
|
||||
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
||||
selectedLanguage === null
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
全部 ({snippets.length})
|
||||
</button>
|
||||
{languages.map(({ lang, count }) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => setSelectedLanguage(selectedLanguage === lang ? null : lang)}
|
||||
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
||||
selectedLanguage === lang
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{lang} ({count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Count */}
|
||||
{(searchQuery || selectedLanguage) && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
找到 {filteredSnippets.length} 个代码片段
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snippet List */}
|
||||
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 380px)' }}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredSnippets.map((snippet) => (
|
||||
<motion.div
|
||||
key={snippet.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
layout
|
||||
>
|
||||
<SnippetCard
|
||||
snippet={snippet}
|
||||
isExpanded={expandedId === snippet.id}
|
||||
onToggle={() => handleToggle(snippet.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{filteredSnippets.length === 0 && (
|
||||
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
没有找到匹配的代码片段
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
desktop/src/components/DetailDrawer.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface DetailDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DetailDrawer({ open, onClose, title = '详情', children }: DetailDrawerProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black/20 dark:bg-black/40 z-40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 抽屉面板 */}
|
||||
<motion.aside
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed right-0 top-0 bottom-0 w-[400px] bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 z-50 flex flex-col shadow-xl"
|
||||
>
|
||||
{/* 抽屉头部 */}
|
||||
<header className="h-14 border-b border-gray-200 dark:border-gray-700 flex items-center px-4 flex-shrink-0">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{title}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-500 dark:text-gray-400 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 抽屉内容 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from './ui/Toast';
|
||||
|
||||
interface HandTaskPanelProps {
|
||||
handId: string;
|
||||
@@ -39,6 +40,7 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; className: string; icon
|
||||
|
||||
export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
|
||||
const { hands, handRuns, loadHands, loadHandRuns, triggerHand, isLoading } = useGatewayStore();
|
||||
const { toast } = useToast();
|
||||
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -78,20 +80,49 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
|
||||
// Trigger hand execution
|
||||
const handleActivate = useCallback(async () => {
|
||||
if (!selectedHand) return;
|
||||
|
||||
// Check if hand is already running
|
||||
if (selectedHand.status === 'running') {
|
||||
toast(`Hand "${selectedHand.name}" 正在运行中,请等待完成`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsActivating(true);
|
||||
console.log(`[HandTaskPanel] Activating hand: ${selectedHand.id} (${selectedHand.name})`);
|
||||
|
||||
try {
|
||||
await triggerHand(selectedHand.id);
|
||||
// Refresh hands list and task history
|
||||
await Promise.all([
|
||||
loadHands(),
|
||||
loadHandRuns(selectedHand.id, { limit: 50 }),
|
||||
]);
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
const result = await triggerHand(selectedHand.id);
|
||||
console.log(`[HandTaskPanel] Activation result:`, result);
|
||||
|
||||
if (result) {
|
||||
toast(`Hand "${selectedHand.name}" 已成功启动`, 'success');
|
||||
// Refresh hands list and task history
|
||||
await Promise.all([
|
||||
loadHands(),
|
||||
loadHandRuns(selectedHand.id, { limit: 50 }),
|
||||
]);
|
||||
} else {
|
||||
// Check for specific error in store
|
||||
const storeError = useGatewayStore.getState().error;
|
||||
if (storeError?.includes('already active')) {
|
||||
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
|
||||
} else {
|
||||
toast(`Hand "${selectedHand.name}" 启动失败: ${storeError || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[HandTaskPanel] Activation error:`, errorMsg);
|
||||
|
||||
if (errorMsg.includes('already active')) {
|
||||
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
|
||||
} else {
|
||||
toast(`Hand "${selectedHand.name}" 启动异常: ${errorMsg}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
}, [selectedHand, triggerHand, loadHands, loadHandRuns]);
|
||||
}, [selectedHand, triggerHand, loadHands, loadHandRuns, toast]);
|
||||
|
||||
if (!selectedHand) {
|
||||
return (
|
||||
|
||||
@@ -158,7 +158,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const { currentAgent } = useChatStore();
|
||||
const agentId = currentAgent?.id || 'default';
|
||||
const agentId = currentAgent?.id || 'zclaw-main';
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
@@ -225,8 +225,8 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
canvas.height = layout.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// 清空画布
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
// 清空画布 - 使用浅色背景匹配系统主题
|
||||
ctx.fillStyle = '#f9fafb'; // gray-50
|
||||
ctx.fillRect(0, 0, layout.width, layout.height);
|
||||
|
||||
// 应用变换
|
||||
@@ -299,7 +299,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
ctx.arc(legendX, legendY, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = colors.fill;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
ctx.fillStyle = '#6b7280'; // gray-500 for better visibility on light background
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(label, legendX + 12, legendY + 4);
|
||||
legendX += 70;
|
||||
@@ -377,7 +377,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-800/50 rounded-t-lg border-b border-gray-700">
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-100 dark:bg-gray-800/50 rounded-t-lg border-b border-gray-200 dark:border-gray-700">
|
||||
{/* 搜索框 */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
@@ -386,7 +386,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
placeholder="搜索记忆..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-2 py-1 bg-gray-900 border border-gray-700 rounded text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
className="w-full pl-8 pr-2 py-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-orange-400 dark:focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -459,7 +459,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden bg-gray-800/30 border-b border-gray-700"
|
||||
className="overflow-hidden bg-gray-50 dark:bg-gray-800/30 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="p-3 flex flex-wrap gap-3">
|
||||
{/* 类型筛选 */}
|
||||
@@ -476,8 +476,8 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
filter.types.includes(type)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{TYPE_LABELS[type]}
|
||||
@@ -510,9 +510,9 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 图谱画布 */}
|
||||
<div className="flex-1 relative overflow-hidden bg-gray-900">
|
||||
<div className="flex-1 relative overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/80 z-10">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-50/80 dark:bg-gray-900/80 z-10">
|
||||
<RefreshCw className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
@@ -541,7 +541,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="absolute top-4 right-4 w-64 bg-gray-800 rounded-lg border border-gray-700 p-4 shadow-xl"
|
||||
className="absolute top-4 right-4 w-64 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant={selectedNode.type as any}>
|
||||
@@ -549,7 +549,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => selectNode(null)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -557,7 +557,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
|
||||
<p className="text-sm text-gray-200 mb-3">{selectedNode.label}</p>
|
||||
|
||||
<div className="space-y-2 text-xs text-gray-400">
|
||||
<div className="space-y-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-3 h-3" />
|
||||
重要性: {selectedNode.importance}
|
||||
@@ -573,9 +573,9 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
</div>
|
||||
|
||||
{/* 关联边统计 */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-400 mb-1">关联记忆:</div>
|
||||
<div className="text-sm text-gray-200">
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">关联记忆:</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{filteredEdges.filter(
|
||||
e => e.source === selectedNode.id || e.target === selectedNode.id
|
||||
).length} 个
|
||||
@@ -598,7 +598,7 @@ export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
||||
</div>
|
||||
|
||||
{/* 状态栏 */}
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-800/50 rounded-b-lg text-xs text-gray-400">
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-800/50 rounded-b-lg text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>节点: {filteredNodes.length}</span>
|
||||
<span>关联: {filteredEdges.length}</span>
|
||||
|
||||
@@ -1,19 +1,80 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getStoredGatewayUrl } from '../lib/gateway-client';
|
||||
import { useGatewayStore, type PluginStatus } from '../store/gatewayStore';
|
||||
import { toChatAgent, useChatStore } from '../store/chatStore';
|
||||
import { toChatAgent, useChatStore, type CodeBlock } from '../store/chatStore';
|
||||
import {
|
||||
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
||||
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain,
|
||||
Shield, Sparkles, GraduationCap
|
||||
MessageSquare, Cpu, FileText, User, Activity, Brain,
|
||||
Shield, Sparkles, GraduationCap, List, Network
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Helper to extract code blocks from markdown content ===
|
||||
function extractCodeBlocksFromContent(content: string): CodeBlock[] {
|
||||
const blocks: CodeBlock[] = [];
|
||||
const regex = /```(\w*)\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const language = match[1] || 'text';
|
||||
const codeContent = match[2].trim();
|
||||
|
||||
// Try to extract filename from first line comment
|
||||
let filename: string | undefined;
|
||||
let actualContent = codeContent;
|
||||
|
||||
// Check for filename patterns like "# filename.py" or "// filename.js"
|
||||
const firstLine = codeContent.split('\n')[0];
|
||||
const filenameMatch = firstLine.match(/^(?:#|\/\/|\/\*|<!--)\s*([^\s]+\.\w+)/);
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1];
|
||||
actualContent = codeContent.split('\n').slice(1).join('\n').trim();
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
language,
|
||||
filename,
|
||||
content: actualContent,
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// === Tab Button Component ===
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
import { MemoryPanel } from './MemoryPanel';
|
||||
import { MemoryGraph } from './MemoryGraph';
|
||||
import { ReflectionLog } from './ReflectionLog';
|
||||
import { AutonomyConfig } from './AutonomyConfig';
|
||||
import { ActiveLearningPanel } from './ActiveLearningPanel';
|
||||
import { CodeSnippetPanel, type CodeSnippet } from './CodeSnippetPanel';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
import { Button, Badge, EmptyState } from './ui';
|
||||
import { Button, Badge } from './ui';
|
||||
import { getPersonalityById } from '../lib/personality-presets';
|
||||
import { silentErrorHandler } from '../lib/error-utils';
|
||||
|
||||
@@ -24,6 +85,7 @@ export function RightPanel() {
|
||||
} = useGatewayStore();
|
||||
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'learning'>('status');
|
||||
const [memoryViewMode, setMemoryViewMode] = useState<'list' | 'graph'>('list');
|
||||
const [isEditingAgent, setIsEditingAgent] = useState(false);
|
||||
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
|
||||
|
||||
@@ -96,108 +158,149 @@ export function RightPanel() {
|
||||
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || '未设置';
|
||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '系统时区';
|
||||
|
||||
// Extract code blocks from all messages (both from codeBlocks property and content parsing)
|
||||
const codeSnippets = useMemo((): CodeSnippet[] => {
|
||||
const snippets: CodeSnippet[] = [];
|
||||
let globalIndex = 0;
|
||||
|
||||
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
|
||||
const msg = messages[msgIdx];
|
||||
|
||||
// First, add any existing codeBlocks from the message
|
||||
if (msg.codeBlocks && msg.codeBlocks.length > 0) {
|
||||
for (const block of msg.codeBlocks) {
|
||||
snippets.push({
|
||||
id: `${msg.id}-codeblock-${globalIndex}`,
|
||||
block,
|
||||
messageIndex: msgIdx,
|
||||
});
|
||||
globalIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Then, extract code blocks from the message content
|
||||
if (msg.content) {
|
||||
const extractedBlocks = extractCodeBlocksFromContent(msg.content);
|
||||
for (const block of extractedBlocks) {
|
||||
snippets.push({
|
||||
id: `${msg.id}-extracted-${globalIndex}`,
|
||||
block,
|
||||
messageIndex: msgIdx,
|
||||
});
|
||||
globalIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snippets;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<aside className="w-80 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span className="font-medium">{messages.length}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">当前消息</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400" role="tablist">
|
||||
<Button
|
||||
variant={activeTab === 'status' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
<aside className="w-full bg-white dark:bg-gray-900 flex flex-col">
|
||||
{/* 顶部工具栏 - Tab 栏 */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
{/* 主 Tab 行 */}
|
||||
<div className="flex items-center px-2 pt-2 gap-1">
|
||||
<TabButton
|
||||
active={activeTab === 'status'}
|
||||
onClick={() => setActiveTab('status')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Status"
|
||||
aria-label="Status"
|
||||
aria-selected={activeTab === 'status'}
|
||||
role="tab"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'files' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('files')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Files"
|
||||
aria-label="Files"
|
||||
aria-selected={activeTab === 'files'}
|
||||
role="tab"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'agent' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="状态"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'agent'}
|
||||
onClick={() => setActiveTab('agent')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Agent"
|
||||
aria-label="Agent"
|
||||
aria-selected={activeTab === 'agent'}
|
||||
role="tab"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'memory' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<User className="w-4 h-4" />}
|
||||
label="Agent"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'files'}
|
||||
onClick={() => setActiveTab('files')}
|
||||
icon={<FileText className="w-4 h-4" />}
|
||||
label="文件"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'memory'}
|
||||
onClick={() => setActiveTab('memory')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Memory"
|
||||
aria-label="Memory"
|
||||
aria-selected={activeTab === 'memory'}
|
||||
role="tab"
|
||||
>
|
||||
<Brain className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'reflection' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<Brain className="w-4 h-4" />}
|
||||
label="记忆"
|
||||
/>
|
||||
</div>
|
||||
{/* 第二行 Tab */}
|
||||
<div className="flex items-center px-2 pb-2 gap-1">
|
||||
<TabButton
|
||||
active={activeTab === 'reflection'}
|
||||
onClick={() => setActiveTab('reflection')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Reflection"
|
||||
aria-label="Reflection"
|
||||
aria-selected={activeTab === 'reflection'}
|
||||
role="tab"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'autonomy' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<Sparkles className="w-4 h-4" />}
|
||||
label="反思"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'autonomy'}
|
||||
onClick={() => setActiveTab('autonomy')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Autonomy"
|
||||
aria-label="Autonomy"
|
||||
aria-selected={activeTab === 'autonomy'}
|
||||
role="tab"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'learning' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
icon={<Shield className="w-4 h-4" />}
|
||||
label="自主"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'learning'}
|
||||
onClick={() => setActiveTab('learning')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Learning"
|
||||
aria-label="Learning"
|
||||
aria-selected={activeTab === 'learning'}
|
||||
role="tab"
|
||||
>
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
</Button>
|
||||
icon={<GraduationCap className="w-4 h-4" />}
|
||||
label="学习"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息统计 */}
|
||||
<div className="px-4 py-2 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
<span>{messages.length} 条消息</span>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span>{userMsgCount} 用户 / {assistantMsgCount} 助手</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${connected ? 'text-emerald-500' : 'text-gray-400'}`}>
|
||||
{connected ? <Wifi className="w-3.5 h-3.5" /> : <WifiOff className="w-3.5 h-3.5" />}
|
||||
<span>{runtimeSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
|
||||
{activeTab === 'memory' ? (
|
||||
<MemoryPanel />
|
||||
<div className="space-y-3">
|
||||
{/* 视图切换 */}
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
onClick={() => setMemoryViewMode('list')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
memoryViewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
列表
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMemoryViewMode('graph')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
memoryViewMode === 'graph'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Network className="w-3.5 h-3.5" />
|
||||
图谱
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{memoryViewMode === 'list' ? (
|
||||
<MemoryPanel />
|
||||
) : (
|
||||
<div className="h-[400px] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<MemoryGraph />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : activeTab === 'reflection' ? (
|
||||
<ReflectionLog />
|
||||
) : activeTab === 'autonomy' ? (
|
||||
@@ -354,90 +457,8 @@ export function RightPanel() {
|
||||
</motion.div>
|
||||
</div>
|
||||
) : activeTab === 'files' ? (
|
||||
<div className="space-y-4">
|
||||
{/* 对话输出文件 */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" />
|
||||
对话输出文件
|
||||
</h3>
|
||||
</div>
|
||||
{messages.filter(m => m.files && m.files.length > 0).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{messages.filter(m => m.files && m.files.length > 0).map((msg, msgIdx) => (
|
||||
<div key={msgIdx} className="space-y-1">
|
||||
{msg.files!.map((file, fileIdx) => (
|
||||
<div
|
||||
key={`${msgIdx}-${fileIdx}`}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||
title={file.path || file.name}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-gray-700 dark:text-gray-200 truncate">{file.name}</div>
|
||||
{file.path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</div>
|
||||
)}
|
||||
</div>
|
||||
{file.size && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
{file.size < 1024 ? `${file.size} B` :
|
||||
file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` :
|
||||
`${(file.size / (1024 * 1024)).toFixed(1)} MB`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<FileCode className="w-8 h-8" />}
|
||||
title="No Output Files"
|
||||
description="Files will appear here when AI uses tools"
|
||||
className="py-4"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 代码块 */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">代码片段</h3>
|
||||
</div>
|
||||
{messages.filter(m => m.codeBlocks && m.codeBlocks.length > 0).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{messages.filter(m => m.codeBlocks && m.codeBlocks.length > 0).flatMap((msg, msgIdx) =>
|
||||
msg.codeBlocks!.map((block, blockIdx) => (
|
||||
<div
|
||||
key={`${msgIdx}-${blockIdx}`}
|
||||
className="px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="default">{block.language || 'code'}</Badge>
|
||||
<span className="text-gray-700 dark:text-gray-200 truncate">{block.filename || 'Untitled'}</span>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-500 dark:text-gray-400 overflow-x-auto max-h-20">
|
||||
{block.content?.slice(0, 200)}{block.content && block.content.length > 200 ? '...' : ''}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
).slice(0, 5)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">No code snippets in conversation</p>
|
||||
)}
|
||||
</motion.div>
|
||||
<div className="p-4">
|
||||
<CodeSnippetPanel snippets={codeSnippets} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useGatewayStore, type Workflow } from '../store/gatewayStore';
|
||||
import { WorkflowEditor } from './WorkflowEditor';
|
||||
import { WorkflowHistory } from './WorkflowHistory';
|
||||
import { TriggersPanel } from './TriggersPanel';
|
||||
import {
|
||||
Clock,
|
||||
Zap,
|
||||
@@ -661,11 +662,6 @@ export function SchedulerPanel() {
|
||||
setIsCreateModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateTrigger = useCallback(() => {
|
||||
// TODO: Implement trigger creation modal
|
||||
alert('事件触发器创建功能即将推出!');
|
||||
}, []);
|
||||
|
||||
const handleCreateSuccess = useCallback(() => {
|
||||
loadScheduledTasks();
|
||||
}, [loadScheduledTasks]);
|
||||
@@ -862,15 +858,7 @@ export function SchedulerPanel() {
|
||||
)}
|
||||
|
||||
{activeTab === 'triggers' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<EmptyState
|
||||
icon={Zap}
|
||||
title="暂无事件触发器"
|
||||
description="事件触发器在系统事件(如收到消息、文件更改或 API webhook)发生时触发代理执行。"
|
||||
actionLabel="创建事件触发器"
|
||||
onAction={handleCreateTrigger}
|
||||
/>
|
||||
</div>
|
||||
<TriggersPanel />
|
||||
)}
|
||||
|
||||
{/* Workflows Tab */}
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Settings, Users, Bot, GitBranch, MessageSquare, Layers, Package } from 'lucide-react';
|
||||
import {
|
||||
Users, Bot, Zap, Layers, Package,
|
||||
Search, Sparkles, ChevronRight, X
|
||||
} from 'lucide-react';
|
||||
import { CloneManager } from './CloneManager';
|
||||
import { HandList } from './HandList';
|
||||
import { WorkflowList } from './WorkflowList';
|
||||
import { AutomationPanel } from './Automation';
|
||||
import { TeamList } from './TeamList';
|
||||
import { SwarmDashboard } from './SwarmDashboard';
|
||||
import { SkillMarket } from './SkillMarket';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { Button } from './ui';
|
||||
import { containerVariants, defaultTransition } from '../lib/animations';
|
||||
|
||||
export type MainViewType = 'chat' | 'hands' | 'workflow' | 'team' | 'swarm' | 'skills';
|
||||
export type MainViewType = 'chat' | 'automation' | 'team' | 'swarm' | 'skills';
|
||||
|
||||
interface SidebarProps {
|
||||
onOpenSettings?: () => void;
|
||||
onMainViewChange?: (view: MainViewType) => void;
|
||||
selectedHandId?: string;
|
||||
onSelectHand?: (handId: string) => void;
|
||||
selectedTeamId?: string;
|
||||
onSelectTeam?: (teamId: string) => void;
|
||||
onNewChat?: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'clones' | 'hands' | 'workflow' | 'team' | 'swarm' | 'skills';
|
||||
type Tab = 'chat' | 'clones' | 'automation' | 'team' | 'swarm' | 'skills';
|
||||
|
||||
const TABS: { key: Tab; label: string; icon: React.ComponentType<{ className?: string }>; mainView?: MainViewType }[] = [
|
||||
// 导航项配置 - WorkBuddy 风格
|
||||
const NAV_ITEMS: {
|
||||
key: Tab;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
mainView?: MainViewType;
|
||||
}[] = [
|
||||
{ key: 'clones', label: '分身', icon: Bot },
|
||||
{ key: 'hands', label: 'Hands', icon: MessageSquare, mainView: 'hands' },
|
||||
{ key: 'workflow', label: '工作流', icon: GitBranch, mainView: 'workflow' },
|
||||
{ key: 'automation', label: '自动化', icon: Zap, mainView: 'automation' },
|
||||
{ key: 'skills', label: '技能', icon: Package, mainView: 'skills' },
|
||||
{ key: 'team', label: '团队', icon: Users, mainView: 'team' },
|
||||
{ key: 'swarm', label: '协作', icon: Layers, mainView: 'swarm' },
|
||||
@@ -36,15 +41,15 @@ const TABS: { key: Tab; label: string; icon: React.ComponentType<{ className?: s
|
||||
export function Sidebar({
|
||||
onOpenSettings,
|
||||
onMainViewChange,
|
||||
selectedHandId,
|
||||
onSelectHand,
|
||||
selectedTeamId,
|
||||
onSelectTeam
|
||||
onSelectTeam,
|
||||
onNewChat
|
||||
}: SidebarProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('clones');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const userName = useGatewayStore((state) => state.quickConfig.userName) || '用户7141';
|
||||
|
||||
const handleTabClick = (key: Tab, mainView?: MainViewType) => {
|
||||
const handleNavClick = (key: Tab, mainView?: MainViewType) => {
|
||||
setActiveTab(key);
|
||||
if (mainView && onMainViewChange) {
|
||||
onMainViewChange(mainView);
|
||||
@@ -53,12 +58,6 @@ export function Sidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHand = (handId: string) => {
|
||||
onSelectHand?.(handId);
|
||||
setActiveTab('hands');
|
||||
onMainViewChange?.('hands');
|
||||
};
|
||||
|
||||
const handleSelectTeam = (teamId: string) => {
|
||||
onSelectTeam?.(teamId);
|
||||
setActiveTab('team');
|
||||
@@ -66,30 +65,65 @@ export function Sidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
|
||||
{/* 顶部标签 - 使用图标 */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800" role="tablist">
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-selected={activeTab === key}
|
||||
role="tab"
|
||||
className={`flex-1 py-2.5 px-2 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
|
||||
activeTab === key
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
onClick={() => handleTabClick(key, TABS.find(t => t.key === key)?.mainView)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
<aside className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded text-gray-400 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* 新对话按钮 */}
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
onClick={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" />
|
||||
<span className="font-medium">新对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航项 */}
|
||||
<nav className="px-3 space-y-0.5">
|
||||
{NAV_ITEMS.map(({ key, label, icon: Icon, mainView }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleNavClick(key, mainView)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
activeTab === key
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${activeTab === key ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400'}`} />
|
||||
<span>{label}</span>
|
||||
{activeTab === key && (
|
||||
<ChevronRight className="w-4 h-4 ml-auto text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<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
|
||||
@@ -99,16 +133,12 @@ export function Sidebar({
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={defaultTransition}
|
||||
className="h-full"
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
{activeTab === 'clones' && <CloneManager />}
|
||||
{activeTab === 'hands' && (
|
||||
<HandList
|
||||
selectedHandId={selectedHandId}
|
||||
onSelectHand={handleSelectHand}
|
||||
/>
|
||||
{activeTab === 'automation' && (
|
||||
<AutomationPanel />
|
||||
)}
|
||||
{activeTab === 'workflow' && <WorkflowList />}
|
||||
{activeTab === 'skills' && <SkillMarket />}
|
||||
{activeTab === 'team' && (
|
||||
<TeamList
|
||||
@@ -121,23 +151,20 @@ export function Sidebar({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 底部用户 */}
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{/* 底部用户栏 */}
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
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">
|
||||
{userName?.charAt(0) || '用'}
|
||||
</div>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300 truncate">{userName}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto p-1.5"
|
||||
onClick={onOpenSettings}
|
||||
aria-label="打开设置"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{userName}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
53
desktop/src/components/TopBar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ClipboardList } from 'lucide-react';
|
||||
import { Button } from './ui';
|
||||
|
||||
interface TopBarProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onOpenDetail?: () => void;
|
||||
showDetailButton?: boolean;
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
title,
|
||||
subtitle,
|
||||
onOpenDetail,
|
||||
showDetailButton = true
|
||||
}: TopBarProps) {
|
||||
return (
|
||||
<header className="h-14 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 flex items-center px-4 flex-shrink-0">
|
||||
{/* 左侧标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-teal-500 rounded-lg flex items-center justify-center text-white font-bold shadow-sm">
|
||||
<span className="text-sm">Z</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">{title}</span>
|
||||
{subtitle && (
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域 */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 右侧按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 详情按钮 */}
|
||||
{showDetailButton && onOpenDetail && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenDetail}
|
||||
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
title="显示详情面板"
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
<span className="text-sm">详情</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.3
|
||||
*/
|
||||
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface IdentityFiles {
|
||||
@@ -200,8 +202,17 @@ export class AgentIdentityManager {
|
||||
agentId: string,
|
||||
file: 'soul' | 'instructions',
|
||||
suggestedContent: string,
|
||||
reason: string
|
||||
): IdentityChangeProposal {
|
||||
reason: string,
|
||||
options?: { skipAutonomyCheck?: boolean }
|
||||
): IdentityChangeProposal | null {
|
||||
// Autonomy check - identity updates are high-risk, always require approval
|
||||
if (!options?.skipAutonomyCheck) {
|
||||
const { decision } = canAutoExecute('identity_update', 8);
|
||||
console.log(`[AgentIdentity] Autonomy check for identity update: ${decision.reason}`);
|
||||
// Identity updates always require approval regardless of autonomy level
|
||||
// But we log the decision for audit purposes
|
||||
}
|
||||
|
||||
const identity = this.getIdentity(agentId);
|
||||
const currentContent = file === 'soul' ? identity.soul : identity.instructions;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type LLMServiceAdapter,
|
||||
type LLMProvider,
|
||||
} from './llm-service';
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -181,8 +182,27 @@ export class ContextCompactor {
|
||||
messages: CompactableMessage[],
|
||||
agentId: string,
|
||||
conversationId?: string,
|
||||
options?: { forceLLM?: boolean }
|
||||
options?: { forceLLM?: boolean; skipAutonomyCheck?: boolean }
|
||||
): Promise<CompactionResult> {
|
||||
// Autonomy check - verify if compaction is allowed
|
||||
if (!options?.skipAutonomyCheck) {
|
||||
const { canProceed, decision } = canAutoExecute('compaction_run', 5);
|
||||
if (!canProceed) {
|
||||
console.log(`[ContextCompactor] Autonomy check failed: ${decision.reason}`);
|
||||
// Return result without compaction
|
||||
return {
|
||||
compactedMessages: messages,
|
||||
summary: '',
|
||||
originalCount: messages.length,
|
||||
retainedCount: messages.length,
|
||||
flushedMemories: 0,
|
||||
tokensBeforeCompaction: estimateMessagesTokens(messages),
|
||||
tokensAfterCompaction: estimateMessagesTokens(messages),
|
||||
};
|
||||
}
|
||||
console.log(`[ContextCompactor] Autonomy check passed: ${decision.reason}`);
|
||||
}
|
||||
|
||||
const tokensBeforeCompaction = estimateMessagesTokens(messages);
|
||||
const keepCount = Math.min(this.config.keepRecentMessages, messages.length);
|
||||
|
||||
|
||||
@@ -958,15 +958,27 @@ export class GatewayClient {
|
||||
|
||||
private async restPost<T>(path: string, body?: unknown): Promise<T> {
|
||||
const baseUrl = this.getRestBaseUrl();
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
const url = `${baseUrl}${path}`;
|
||||
console.log(`[GatewayClient] POST ${url}`, body);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
console.error(`[GatewayClient] POST ${url} failed: ${response.status} ${response.statusText}`, errorBody);
|
||||
const error = new Error(`REST API error: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
||||
(error as any).status = response.status;
|
||||
(error as any).body = errorBody;
|
||||
throw error;
|
||||
}
|
||||
return response.json();
|
||||
|
||||
const result = await response.json();
|
||||
console.log(`[GatewayClient] POST ${url} response:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async restPut<T>(path: string, body?: unknown): Promise<T> {
|
||||
@@ -1318,12 +1330,19 @@ export class GatewayClient {
|
||||
|
||||
/** Trigger a Hand */
|
||||
async triggerHand(name: string, params?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
|
||||
console.log(`[GatewayClient] Triggering hand: ${name}`, params);
|
||||
// OpenFang uses /activate endpoint, not /trigger
|
||||
const result = await this.restPost<{
|
||||
instance_id: string;
|
||||
status: string;
|
||||
}>(`/api/hands/${name}/activate`, params || {});
|
||||
return { runId: result.instance_id, status: result.status };
|
||||
try {
|
||||
const result = await this.restPost<{
|
||||
instance_id: string;
|
||||
status: string;
|
||||
}>(`/api/hands/${name}/activate`, params || {});
|
||||
console.log(`[GatewayClient] Hand trigger response:`, result);
|
||||
return { runId: result.instance_id, status: result.status };
|
||||
} catch (err) {
|
||||
console.error(`[GatewayClient] Hand trigger failed for ${name}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get Hand execution status */
|
||||
|
||||
@@ -295,21 +295,59 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
|
||||
const config = { ...this.config, ...options };
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await fetch(`${config.apiBase}/complete`, {
|
||||
// Build a single prompt from messages
|
||||
const systemMessage = messages.find(m => m.role === 'system')?.content || '';
|
||||
const userMessage = messages.find(m => m.role === 'user')?.content || '';
|
||||
|
||||
// Combine system and user messages into a single prompt
|
||||
const fullPrompt = systemMessage
|
||||
? `${systemMessage}\n\n${userMessage}`
|
||||
: userMessage;
|
||||
|
||||
// Use OpenFang's chat endpoint (same as main chat)
|
||||
// Try to get the default agent ID from localStorage or use 'default'
|
||||
const agentId = localStorage.getItem('zclaw-default-agent-id') || 'default';
|
||||
|
||||
const response = await fetch(`/api/agents/${agentId}/message`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
message: fullPrompt,
|
||||
max_tokens: config.maxTokens,
|
||||
temperature: config.temperature,
|
||||
temperature: config.temperature ?? 0.3, // Lower temperature for extraction tasks
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.timeout || 60000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
// If agent not found, try without agent ID (direct /api/chat)
|
||||
if (response.status === 404) {
|
||||
const fallbackResponse = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: fullPrompt,
|
||||
max_tokens: config.maxTokens,
|
||||
temperature: config.temperature ?? 0.3,
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.timeout || 60000),
|
||||
});
|
||||
|
||||
if (!fallbackResponse.ok) {
|
||||
throw new Error(`[Gateway] Both endpoints failed: ${fallbackResponse.status}`);
|
||||
}
|
||||
|
||||
const data = await fallbackResponse.json();
|
||||
const latencyMs = Date.now() - startTime;
|
||||
return {
|
||||
content: data.response || data.content || '',
|
||||
tokensUsed: { input: data.input_tokens || 0, output: data.output_tokens || 0 },
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
throw new Error(`[Gateway] API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
@@ -317,15 +355,14 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
content: data.content || data.choices?.[0]?.message?.content || '',
|
||||
tokensUsed: data.tokensUsed || { input: 0, output: 0 },
|
||||
model: data.model,
|
||||
content: data.response || data.content || '',
|
||||
tokensUsed: { input: data.input_tokens || 0, output: data.output_tokens || 0 },
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
// Gateway is available if we're connected to OpenFang
|
||||
// Gateway is available if we're in browser (can connect to OpenFang)
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
@@ -382,8 +419,8 @@ export function loadConfig(): LLMConfig {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
// Default to mock for safety
|
||||
return DEFAULT_CONFIGS.mock;
|
||||
// Default to gateway (OpenFang passthrough) for L4 self-evolution
|
||||
return DEFAULT_CONFIGS.gateway;
|
||||
}
|
||||
|
||||
export function saveConfig(config: LLMConfig): void {
|
||||
|
||||
@@ -80,9 +80,9 @@ const EXTRACTION_PROMPT = `请从以下对话中提取值得长期记住的信
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_EXTRACTION_CONFIG: ExtractionConfig = {
|
||||
useLLM: false,
|
||||
useLLM: true, // Enable LLM-powered semantic extraction by default
|
||||
llmFallbackToRules: true,
|
||||
minMessagesForExtraction: 4,
|
||||
minMessagesForExtraction: 2, // Lowered from 4 to capture memories earlier
|
||||
extractionCooldownMs: 30_000,
|
||||
minImportanceThreshold: 3,
|
||||
};
|
||||
@@ -119,12 +119,15 @@ export class MemoryExtractor {
|
||||
): Promise<ExtractionResult> {
|
||||
// Cooldown check
|
||||
if (Date.now() - this.lastExtractionTime < this.config.extractionCooldownMs) {
|
||||
console.log('[MemoryExtractor] Skipping extraction: cooldown active');
|
||||
return { items: [], saved: 0, skipped: 0, userProfileUpdated: false };
|
||||
}
|
||||
|
||||
// Minimum message threshold
|
||||
const chatMessages = messages.filter(m => m.role === 'user' || m.role === 'assistant');
|
||||
console.log(`[MemoryExtractor] Checking extraction: ${chatMessages.length} messages (min: ${this.config.minMessagesForExtraction})`);
|
||||
if (chatMessages.length < this.config.minMessagesForExtraction) {
|
||||
console.log('[MemoryExtractor] Skipping extraction: not enough messages');
|
||||
return { items: [], saved: 0, skipped: 0, userProfileUpdated: false };
|
||||
}
|
||||
|
||||
@@ -146,11 +149,14 @@ export class MemoryExtractor {
|
||||
}
|
||||
} else {
|
||||
// Rule-based extraction
|
||||
console.log('[MemoryExtractor] Using rule-based extraction');
|
||||
extracted = this.ruleBasedExtraction(chatMessages);
|
||||
console.log(`[MemoryExtractor] Rule-based extracted ${extracted.length} items before filtering`);
|
||||
}
|
||||
|
||||
// Filter by importance threshold
|
||||
extracted = extracted.filter(item => item.importance >= this.config.minImportanceThreshold);
|
||||
console.log(`[MemoryExtractor] After importance filtering (>= ${this.config.minImportanceThreshold}): ${extracted.length} items`);
|
||||
|
||||
// Save to memory
|
||||
const memoryManager = getMemoryManager();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type LLMServiceAdapter,
|
||||
type LLMProvider,
|
||||
} from './llm-service';
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -62,7 +63,7 @@ export const DEFAULT_REFLECTION_CONFIG: ReflectionConfig = {
|
||||
triggerAfterHours: 24,
|
||||
allowSoulModification: false,
|
||||
requireApproval: true,
|
||||
useLLM: false,
|
||||
useLLM: true, // Enable LLM-powered deep reflection (Phase 4)
|
||||
llmFallbackToRules: true,
|
||||
};
|
||||
|
||||
@@ -137,9 +138,26 @@ export class ReflectionEngine {
|
||||
/**
|
||||
* Execute a reflection cycle for the given agent.
|
||||
*/
|
||||
async reflect(agentId: string, options?: { forceLLM?: boolean }): Promise<ReflectionResult> {
|
||||
async reflect(agentId: string, options?: { forceLLM?: boolean; skipAutonomyCheck?: boolean }): Promise<ReflectionResult> {
|
||||
console.log(`[Reflection] Starting reflection for agent: ${agentId}`);
|
||||
|
||||
// Autonomy check - verify if reflection is allowed
|
||||
if (!options?.skipAutonomyCheck) {
|
||||
const { canProceed, decision } = canAutoExecute('reflection_run', 5);
|
||||
if (!canProceed) {
|
||||
console.log(`[Reflection] Autonomy check failed: ${decision.reason}`);
|
||||
// Return empty result instead of throwing
|
||||
return {
|
||||
patterns: [],
|
||||
improvements: [],
|
||||
identityProposals: [],
|
||||
newMemories: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
console.log(`[Reflection] Autonomy check passed: ${decision.reason}`);
|
||||
}
|
||||
|
||||
// Try LLM-powered reflection if enabled
|
||||
if ((this.config.useLLM || options?.forceLLM) && this.llmAdapter?.isAvailable()) {
|
||||
try {
|
||||
@@ -575,7 +593,9 @@ ${recentHistory || '无'}
|
||||
identity.instructions + `\n\n## 自我反思改进\n${additions}`,
|
||||
`基于 ${negativePatterns.length} 个负面模式观察,建议在指令中增加自我改进提醒`
|
||||
);
|
||||
proposals.push(proposal);
|
||||
if (proposal) {
|
||||
proposals.push(proposal);
|
||||
}
|
||||
}
|
||||
|
||||
return proposals;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { getMemoryManager } from './agent-memory';
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -365,13 +366,33 @@ export class SkillDiscoveryEngine {
|
||||
|
||||
/**
|
||||
* Mark a skill as installed/uninstalled.
|
||||
* Includes autonomy check for skill_install/skill_uninstall actions.
|
||||
*/
|
||||
setSkillInstalled(skillId: string, installed: boolean): void {
|
||||
setSkillInstalled(
|
||||
skillId: string,
|
||||
installed: boolean,
|
||||
options?: { skipAutonomyCheck?: boolean }
|
||||
): { success: boolean; reason?: string } {
|
||||
const skill = this.skills.find(s => s.id === skillId);
|
||||
if (skill) {
|
||||
skill.installed = installed;
|
||||
this.saveIndex();
|
||||
if (!skill) {
|
||||
return { success: false, reason: `Skill not found: ${skillId}` };
|
||||
}
|
||||
|
||||
// Autonomy check - verify if skill installation is allowed
|
||||
if (!options?.skipAutonomyCheck) {
|
||||
const action = installed ? 'skill_install' : 'skill_uninstall';
|
||||
const { canProceed, decision } = canAutoExecute(action, 6);
|
||||
console.log(`[SkillDiscovery] Autonomy check for ${action}: ${decision.reason}`);
|
||||
|
||||
if (!canProceed) {
|
||||
return { success: false, reason: decision.reason };
|
||||
}
|
||||
}
|
||||
|
||||
skill.installed = installed;
|
||||
this.saveIndex();
|
||||
console.log(`[SkillDiscovery] Skill ${skillId} ${installed ? 'installed' : 'uninstalled'}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { ToastProvider } from './components/ui/Toast';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -403,12 +403,21 @@ export const useChatStore = create<ChatState>()(
|
||||
set((state) => ({ messages: [...state.messages, handMsg] }));
|
||||
},
|
||||
onComplete: () => {
|
||||
set((state) => ({
|
||||
const state = get();
|
||||
|
||||
// Save conversation to persist across refresh
|
||||
const conversations = upsertActiveConversation([...state.conversations], state);
|
||||
const currentConvId = state.currentConversationId || conversations[0]?.id;
|
||||
|
||||
set({
|
||||
isStreaming: false,
|
||||
conversations,
|
||||
currentConversationId: currentConvId,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, streaming: false, runId } : m
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
// Async memory extraction after stream completes
|
||||
const msgs = get().messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
|
||||
@@ -1294,11 +1294,15 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
},
|
||||
|
||||
triggerHand: async (name: string, params?: Record<string, unknown>) => {
|
||||
console.log(`[GatewayStore] Triggering hand: ${name}`, params);
|
||||
try {
|
||||
const result = await get().client.triggerHand(name, params);
|
||||
console.log(`[GatewayStore] Hand trigger result:`, result);
|
||||
return result ? { runId: result.runId, status: result.status, startedAt: new Date().toISOString() } : undefined;
|
||||
} catch (err: unknown) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[GatewayStore] Hand trigger error:`, errorMsg, err);
|
||||
set({ error: errorMsg });
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 74 KiB |