mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-08 02:14:08 +08:00
feat: 添加左侧面板和节点库组件,整合模板和节点功能
feat: 实现可折叠的模板面板,支持搜索和安装模板 feat: 更新流程工具栏,增加导入模板和模拟运行功能 feat: 增强属性面板,支持标签和产物管理 feat: 优化提示模板节点,增加执行状态和阶段显示
This commit is contained in:
1
ccw/docs-site/.gitignore
vendored
Normal file
1
ccw/docs-site/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.ace-tool/
|
||||
@@ -1,12 +1,11 @@
|
||||
// ========================================
|
||||
// Flow Toolbar Component
|
||||
// ========================================
|
||||
// Toolbar for flow operations: New, Save, Load, Export
|
||||
// Toolbar for flow operations: Save, Load, Import Template, Export, Simulate, Run
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Plus,
|
||||
Save,
|
||||
FolderOpen,
|
||||
Download,
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Library,
|
||||
Play,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -39,7 +40,6 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
const isModified = useFlowStore((state) => state.isModified);
|
||||
const flows = useFlowStore((state) => state.flows);
|
||||
const isLoadingFlows = useFlowStore((state) => state.isLoadingFlows);
|
||||
const createFlow = useFlowStore((state) => state.createFlow);
|
||||
const saveFlow = useFlowStore((state) => state.saveFlow);
|
||||
const loadFlow = useFlowStore((state) => state.loadFlow);
|
||||
const deleteFlow = useFlowStore((state) => state.deleteFlow);
|
||||
@@ -56,13 +56,6 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
setFlowName(currentFlow?.name || '');
|
||||
}, [currentFlow?.name]);
|
||||
|
||||
// Handle new flow
|
||||
const handleNew = useCallback(() => {
|
||||
const newFlow = createFlow('Untitled Flow', 'A new workflow');
|
||||
setFlowName(newFlow.name);
|
||||
toast.success('Flow Created', 'New flow created successfully');
|
||||
}, [createFlow]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentFlow) {
|
||||
@@ -186,11 +179,7 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleNew}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
New
|
||||
</Button>
|
||||
|
||||
{/* Save & Load Group */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -290,17 +279,31 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={!currentFlow}>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Export
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* Import & Export Group */}
|
||||
<Button variant="outline" size="sm" onClick={onOpenTemplateLibrary}>
|
||||
<Library className="w-4 h-4 mr-1" />
|
||||
Templates
|
||||
Import Template
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={!currentFlow}>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Export Flow
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* Run Group */}
|
||||
<Button variant="outline" size="sm" disabled title="Coming soon">
|
||||
<FlaskConical className="w-4 h-4 mr-1" />
|
||||
Simulate
|
||||
</Button>
|
||||
|
||||
<Button variant="default" size="sm" disabled title="Coming soon">
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Run Workflow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
164
ccw/frontend/src/pages/orchestrator/InlineTemplatePanel.tsx
Normal file
164
ccw/frontend/src/pages/orchestrator/InlineTemplatePanel.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
// ========================================
|
||||
// Inline Template Panel Component
|
||||
// ========================================
|
||||
// Compact template list for the left sidebar, uses useTemplates hook
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Search, Loader2, FileText, Download, GitBranch } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTemplates, useInstallTemplate } from '@/hooks/useTemplates';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import type { FlowTemplate } from '@/types/execution';
|
||||
|
||||
// ========== Sub-Components ==========
|
||||
|
||||
interface TemplateItemProps {
|
||||
template: FlowTemplate;
|
||||
onInstall: (template: FlowTemplate) => void;
|
||||
isInstalling: boolean;
|
||||
}
|
||||
|
||||
function TemplateItem({ template, onInstall, isInstalling }: TemplateItemProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onInstall(template)}
|
||||
disabled={isInstalling}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-left transition-colors',
|
||||
'hover:bg-muted/60 active:bg-muted',
|
||||
isInstalling && 'opacity-50 cursor-wait'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{template.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
{template.nodeCount} nodes
|
||||
</span>
|
||||
{template.category && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{template.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 text-muted-foreground shrink-0 opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
interface InlineTemplatePanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact template browser for the left sidebar.
|
||||
* Loads templates via the useTemplates API hook and displays them in a searchable list.
|
||||
* Clicking a template installs it as the current flow.
|
||||
*/
|
||||
export function InlineTemplatePanel({ className }: InlineTemplatePanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||
|
||||
const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow);
|
||||
|
||||
const { data, isLoading, error } = useTemplates();
|
||||
const installTemplate = useInstallTemplate();
|
||||
|
||||
// Filter templates by search query
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (!data?.templates) return [];
|
||||
if (!searchQuery.trim()) return data.templates;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return data.templates.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.description?.toLowerCase().includes(query) ||
|
||||
t.category?.toLowerCase().includes(query) ||
|
||||
t.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}, [data?.templates, searchQuery]);
|
||||
|
||||
// Handle install - load template as current flow
|
||||
const handleInstall = useCallback(
|
||||
async (template: FlowTemplate) => {
|
||||
setInstallingId(template.id);
|
||||
try {
|
||||
const result = await installTemplate.mutateAsync({
|
||||
templateId: template.id,
|
||||
});
|
||||
setCurrentFlow(result.flow);
|
||||
} catch (err) {
|
||||
console.error('Failed to install template:', err);
|
||||
} finally {
|
||||
setInstallingId(null);
|
||||
}
|
||||
},
|
||||
[installTemplate, setCurrentFlow]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex-1 flex flex-col overflow-hidden', className)}>
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索模板..."
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template List */}
|
||||
<div className="flex-1 overflow-y-auto px-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground px-4">
|
||||
<FileText className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-xs text-center">
|
||||
无法加载模板库,请确认 API 服务可用
|
||||
</p>
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground px-4">
|
||||
<FileText className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-xs text-center">
|
||||
{searchQuery ? '未找到匹配的模板' : '暂无可用模板'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{filteredTemplates.map((template) => (
|
||||
<TemplateItem
|
||||
key={template.id}
|
||||
template={template}
|
||||
onInstall={handleInstall}
|
||||
isInstalling={installingId === template.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InlineTemplatePanel;
|
||||
105
ccw/frontend/src/pages/orchestrator/LeftSidebar.tsx
Normal file
105
ccw/frontend/src/pages/orchestrator/LeftSidebar.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
// ========================================
|
||||
// Left Sidebar Component
|
||||
// ========================================
|
||||
// Container with tab switching between NodeLibrary and InlineTemplatePanel
|
||||
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { NodeLibrary } from './NodeLibrary';
|
||||
import { InlineTemplatePanel } from './InlineTemplatePanel';
|
||||
|
||||
// ========== Tab Configuration ==========
|
||||
|
||||
const TABS: Array<{ key: 'templates' | 'nodes'; label: string }> = [
|
||||
{ key: 'templates', label: '\u6A21\u677F\u5E93' },
|
||||
{ key: 'nodes', label: '\u8282\u70B9\u5E93' },
|
||||
];
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
interface LeftSidebarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left sidebar container with collapsible panel and tab switching.
|
||||
* Renders either InlineTemplatePanel or NodeLibrary based on active tab.
|
||||
*/
|
||||
export function LeftSidebar({ className }: LeftSidebarProps) {
|
||||
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
|
||||
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
|
||||
const leftPanelTab = useFlowStore((state) => state.leftPanelTab);
|
||||
const setLeftPanelTab = useFlowStore((state) => state.setLeftPanelTab);
|
||||
|
||||
// Collapsed state
|
||||
if (!isPaletteOpen) {
|
||||
return (
|
||||
<div className={cn('w-10 bg-card border-r border-border flex flex-col items-center py-4', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsPaletteOpen(true)}
|
||||
title="展开面板"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state
|
||||
return (
|
||||
<div className={cn('w-72 bg-card border-r border-border flex flex-col', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 className="font-semibold text-foreground">工作台</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsPaletteOpen(false)}
|
||||
title="折叠面板"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab Bar */}
|
||||
<div className="flex border-b border-border">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setLeftPanelTab(tab.key)}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 text-sm font-medium text-center transition-colors',
|
||||
'hover:text-foreground',
|
||||
leftPanelTab === tab.key
|
||||
? 'text-foreground border-b-2 border-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{leftPanelTab === 'templates' ? (
|
||||
<InlineTemplatePanel />
|
||||
) : (
|
||||
<NodeLibrary />
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-border bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Tip:</span> 拖拽到画布或双击添加
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeftSidebar;
|
||||
208
ccw/frontend/src/pages/orchestrator/NodeLibrary.tsx
Normal file
208
ccw/frontend/src/pages/orchestrator/NodeLibrary.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
// ========================================
|
||||
// Node Library Component
|
||||
// ========================================
|
||||
// Displays quick templates organized by category (phase / tool / command)
|
||||
// Extracted from NodePalette for use inside LeftSidebar
|
||||
|
||||
import { DragEvent, useState } from 'react';
|
||||
import {
|
||||
MessageSquare, ChevronDown, ChevronRight, GripVertical,
|
||||
Search, Code, Terminal, Plus,
|
||||
FolderOpen, Database, ListTodo, Play, CheckCircle,
|
||||
FolderSearch, GitMerge, ListChecks,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { NODE_TYPE_CONFIGS, QUICK_TEMPLATES } from '@/types/flow';
|
||||
import type { QuickTemplate } from '@/types/flow';
|
||||
|
||||
// ========== Icon Mapping ==========
|
||||
|
||||
const TEMPLATE_ICONS: Record<string, React.ElementType> = {
|
||||
// Command templates
|
||||
'slash-command-main': Terminal,
|
||||
'slash-command-async': Terminal,
|
||||
analysis: Search,
|
||||
implementation: Code,
|
||||
// Phase templates
|
||||
'phase-session': FolderOpen,
|
||||
'phase-context': Database,
|
||||
'phase-plan': ListTodo,
|
||||
'phase-execute': Play,
|
||||
'phase-review': CheckCircle,
|
||||
// Tool templates
|
||||
'tool-context-gather': FolderSearch,
|
||||
'tool-conflict-resolution': GitMerge,
|
||||
'tool-task-generate': ListChecks,
|
||||
};
|
||||
|
||||
// ========== Category Configuration ==========
|
||||
|
||||
const CATEGORY_CONFIG: Record<QuickTemplate['category'], { title: string; defaultExpanded: boolean }> = {
|
||||
phase: { title: '\u9636\u6BB5\u8282\u70B9', defaultExpanded: true },
|
||||
tool: { title: '\u5DE5\u5177\u8282\u70B9', defaultExpanded: true },
|
||||
command: { title: '\u547D\u4EE4', defaultExpanded: false },
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: QuickTemplate['category'][] = ['phase', 'tool', 'command'];
|
||||
|
||||
// ========== Sub-Components ==========
|
||||
|
||||
/**
|
||||
* Collapsible category section
|
||||
*/
|
||||
function TemplateCategory({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
{title}
|
||||
</button>
|
||||
|
||||
{isExpanded && <div className="space-y-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draggable card for a quick template
|
||||
*/
|
||||
function QuickTemplateCard({
|
||||
template,
|
||||
}: {
|
||||
template: QuickTemplate;
|
||||
}) {
|
||||
const Icon = TEMPLATE_ICONS[template.id] || MessageSquare;
|
||||
|
||||
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template');
|
||||
event.dataTransfer.setData('application/reactflow-template-id', template.id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const onDoubleClick = () => {
|
||||
const position = { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 };
|
||||
useFlowStore.getState().addNodeFromTemplate(template.id, position);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 p-3 rounded-lg border bg-card cursor-grab transition-all',
|
||||
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
|
||||
`border-${template.color.replace('bg-', '')}`
|
||||
)}
|
||||
>
|
||||
<div className={cn('p-2 rounded-md text-white', template.color)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{template.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{template.description}</div>
|
||||
</div>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic empty prompt template card
|
||||
*/
|
||||
function BasicTemplateCard() {
|
||||
const config = NODE_TYPE_CONFIGS['prompt-template'];
|
||||
|
||||
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const onDoubleClick = () => {
|
||||
const position = { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 };
|
||||
useFlowStore.getState().addNode(position);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 p-3 rounded-lg border bg-card cursor-grab transition-all',
|
||||
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
|
||||
'border-dashed border-muted-foreground/50 hover:border-primary',
|
||||
)}
|
||||
>
|
||||
<div className="p-2 rounded-md text-white bg-blue-500 hover:bg-blue-600">
|
||||
<Plus className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{config.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{config.description}</div>
|
||||
</div>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
interface NodeLibraryProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node library panel displaying quick templates grouped by category.
|
||||
* Renders a scrollable list of template cards organized into collapsible sections.
|
||||
* Used inside LeftSidebar - does not manage its own header/footer/collapse state.
|
||||
*/
|
||||
export function NodeLibrary({ className }: NodeLibraryProps) {
|
||||
return (
|
||||
<div className={cn('flex-1 overflow-y-auto p-4 space-y-4', className)}>
|
||||
{/* Basic / Empty Template */}
|
||||
<TemplateCategory title="Basic" defaultExpanded={false}>
|
||||
<BasicTemplateCard />
|
||||
</TemplateCategory>
|
||||
|
||||
{/* Category groups in order: phase -> tool -> command */}
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const config = CATEGORY_CONFIG[category];
|
||||
const templates = QUICK_TEMPLATES.filter((t) => t.category === category);
|
||||
if (templates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<TemplateCategory
|
||||
key={category}
|
||||
title={config.title}
|
||||
defaultExpanded={config.defaultExpanded}
|
||||
>
|
||||
{templates.map((template) => (
|
||||
<QuickTemplateCard key={template.id} template={template} />
|
||||
))}
|
||||
</TemplateCategory>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeLibrary;
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { FlowCanvas } from './FlowCanvas';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { LeftSidebar } from './LeftSidebar';
|
||||
import { PropertyPanel } from './PropertyPanel';
|
||||
import { FlowToolbar } from './FlowToolbar';
|
||||
import { TemplateLibrary } from './TemplateLibrary';
|
||||
@@ -32,8 +32,8 @@ export function OrchestratorPage() {
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Node Palette (Left) */}
|
||||
<NodePalette />
|
||||
{/* Left Sidebar (Templates + Nodes) */}
|
||||
<LeftSidebar />
|
||||
|
||||
{/* Flow Canvas (Center) */}
|
||||
<div className="flex-1 relative">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useCallback, useMemo, useState, useEffect, useRef, KeyboardEvent } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Settings, X, MessageSquare, Trash2, AlertCircle, CheckCircle2, Plus, Save } from 'lucide-react';
|
||||
import { Settings, X, MessageSquare, Trash2, AlertCircle, CheckCircle2, Plus, Save, Copy, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -786,6 +786,176 @@ function SlashCommandSection({ data, onChange, availableVariables }: SlashComman
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Collapsible Section ==========
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
defaultExpanded = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
defaultExpanded?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
return (
|
||||
<div className="border-t border-border pt-3">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
{title}
|
||||
</button>
|
||||
{isExpanded && <div className="space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Tags Input ==========
|
||||
|
||||
function TagsInput({ tags, onChange }: { tags: string[]; onChange: (tags: string[]) => void }) {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (input.trim() && !tags.includes(input.trim())) {
|
||||
onChange([...tags, input.trim()]);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (tag: string) => {
|
||||
onChange(tags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
|
||||
{tag}
|
||||
<button onClick={() => handleRemove(tag)} className="hover:text-destructive">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="添加标签..."
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={handleAdd} disabled={!input.trim()} className="h-7 px-2">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Artifacts List ==========
|
||||
|
||||
function ArtifactsList({ artifacts, onChange }: { artifacts: string[]; onChange: (artifacts: string[]) => void }) {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (input.trim()) {
|
||||
onChange([...artifacts, input.trim()]);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(artifacts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{artifacts.map((artifact, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{'->'}</span>
|
||||
<span className="flex-1 font-mono truncate">{artifact}</span>
|
||||
<button onClick={() => handleRemove(i)} className="text-muted-foreground hover:text-destructive">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
placeholder="output-file.json"
|
||||
className="h-7 text-xs font-mono"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={handleAdd} disabled={!input.trim()} className="h-7 px-2">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Script Preview ==========
|
||||
|
||||
function ScriptPreview({ data }: { data: PromptTemplateNodeData }) {
|
||||
const script = useMemo(() => {
|
||||
// Slash command mode
|
||||
if (data.slashCommand) {
|
||||
const args = data.slashArgs ? ` ${data.slashArgs}` : '';
|
||||
return `/${data.slashCommand}${args}`;
|
||||
}
|
||||
|
||||
// CLI tool mode
|
||||
if (data.tool && (data.mode === 'analysis' || data.mode === 'write')) {
|
||||
const parts = ['ccw cli'];
|
||||
parts.push(`--tool ${data.tool}`);
|
||||
parts.push(`--mode ${data.mode}`);
|
||||
if (data.instruction) {
|
||||
const snippet = data.instruction.slice(0, 80).replace(/\n/g, ' ');
|
||||
parts.push(`-p "${snippet}..."`);
|
||||
}
|
||||
return parts.join(' \\\n ');
|
||||
}
|
||||
|
||||
// Plain instruction
|
||||
if (data.instruction) {
|
||||
return `# ${data.instruction.slice(0, 100)}`;
|
||||
}
|
||||
|
||||
return '# 未配置命令';
|
||||
}, [data.slashCommand, data.slashArgs, data.tool, data.mode, data.instruction]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(script);
|
||||
}, [script]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre className="p-3 rounded-md bg-muted/50 font-mono text-xs text-foreground/80 overflow-x-auto whitespace-pre-wrap border border-border">
|
||||
{script}
|
||||
</pre>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Unified PromptTemplate Property Editor ==========
|
||||
|
||||
interface PromptTemplatePropertiesProps {
|
||||
@@ -867,6 +1037,75 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification Section */}
|
||||
<CollapsibleSection title="分类信息" defaultExpanded={false}>
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">描述</label>
|
||||
<textarea
|
||||
value={data.description || ''}
|
||||
onChange={(e) => onChange({ description: e.target.value })}
|
||||
placeholder="节点功能描述..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phase */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">阶段</label>
|
||||
<select
|
||||
value={data.phase || ''}
|
||||
onChange={(e) => onChange({ phase: (e.target.value || undefined) as PromptTemplateNodeData['phase'] })}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">无</option>
|
||||
<option value="session">Session</option>
|
||||
<option value="context">Context</option>
|
||||
<option value="plan">Plan</option>
|
||||
<option value="execute">Execute</option>
|
||||
<option value="review">Review</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">标签</label>
|
||||
<TagsInput
|
||||
tags={data.tags || []}
|
||||
onChange={(tags) => onChange({ tags })}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Execution Section */}
|
||||
<CollapsibleSection title="执行配置" defaultExpanded={false}>
|
||||
{/* Condition */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">条件</label>
|
||||
<Input
|
||||
value={data.condition || ''}
|
||||
onChange={(e) => onChange({ condition: e.target.value || undefined })}
|
||||
placeholder="例如: {{prev.success}} === true"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Artifacts */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">产物</label>
|
||||
<ArtifactsList
|
||||
artifacts={data.artifacts || []}
|
||||
onChange={(artifacts) => onChange({ artifacts })}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Script Preview Section */}
|
||||
<CollapsibleSection title="脚本预览" defaultExpanded={true}>
|
||||
<ScriptPreview data={data} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface NodeWrapperProps {
|
||||
children: ReactNode;
|
||||
status?: ExecutionStatus;
|
||||
selected?: boolean;
|
||||
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
||||
accentColor: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ const SELECTION_STYLES: Record<string, string> = {
|
||||
green: 'ring-2 ring-green-500/20 border-green-500',
|
||||
amber: 'ring-2 ring-amber-500/20 border-amber-500',
|
||||
purple: 'ring-2 ring-purple-500/20 border-purple-500',
|
||||
sky: 'ring-2 ring-sky-500/20 border-sky-500',
|
||||
cyan: 'ring-2 ring-cyan-500/20 border-cyan-500',
|
||||
teal: 'ring-2 ring-teal-500/20 border-teal-500',
|
||||
orange: 'ring-2 ring-orange-500/20 border-orange-500',
|
||||
indigo: 'ring-2 ring-indigo-500/20 border-indigo-500',
|
||||
};
|
||||
|
||||
// Status icons
|
||||
|
||||
@@ -33,6 +33,40 @@ const TOOL_STYLES: Record<string, string> = {
|
||||
claude: 'bg-amber-50 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400',
|
||||
};
|
||||
|
||||
// Phase color strip mapping
|
||||
const PHASE_COLORS: Record<string, string> = {
|
||||
session: 'bg-sky-500',
|
||||
context: 'bg-cyan-500',
|
||||
plan: 'bg-amber-500',
|
||||
execute: 'bg-green-500',
|
||||
review: 'bg-purple-500',
|
||||
};
|
||||
|
||||
// Phase-based node theme: header bg, handle color, accent for selection ring
|
||||
const PHASE_THEME: Record<string, { header: string; handle: string; accent: string }> = {
|
||||
session: { header: 'bg-sky-500', handle: '!bg-sky-500', accent: 'sky' },
|
||||
context: { header: 'bg-cyan-500', handle: '!bg-cyan-500', accent: 'cyan' },
|
||||
plan: { header: 'bg-amber-500', handle: '!bg-amber-500', accent: 'amber' },
|
||||
execute: { header: 'bg-green-500', handle: '!bg-green-500', accent: 'green' },
|
||||
review: { header: 'bg-purple-500', handle: '!bg-purple-500', accent: 'purple' },
|
||||
};
|
||||
|
||||
// nodeCategory-based fallback theme (when no phase set)
|
||||
const CATEGORY_THEME: Record<string, { header: string; handle: string; accent: string }> = {
|
||||
tool: { header: 'bg-teal-500', handle: '!bg-teal-500', accent: 'teal' },
|
||||
command: { header: 'bg-blue-500', handle: '!bg-blue-500', accent: 'blue' },
|
||||
};
|
||||
|
||||
const DEFAULT_THEME = { header: 'bg-blue-500', handle: '!bg-blue-500', accent: 'blue' };
|
||||
|
||||
// Execution status indicators
|
||||
const STATUS_INDICATORS: Record<string, { color: string; label: string }> = {
|
||||
pending: { color: 'text-slate-400', label: 'Ready' },
|
||||
running: { color: 'text-amber-500', label: 'Running' },
|
||||
completed: { color: 'text-emerald-500', label: 'Done' },
|
||||
failed: { color: 'text-red-500', label: 'Failed' },
|
||||
};
|
||||
|
||||
export const PromptTemplateNode = memo(({ data, selected }: PromptTemplateNodeProps) => {
|
||||
// Truncate instruction for display (max 50 chars)
|
||||
const displayInstruction = data.instruction
|
||||
@@ -42,99 +76,163 @@ export const PromptTemplateNode = memo(({ data, selected }: PromptTemplateNodePr
|
||||
: 'No instruction';
|
||||
|
||||
const hasContextRefs = data.contextRefs && data.contextRefs.length > 0;
|
||||
const hasPhase = data.phase && PHASE_COLORS[data.phase];
|
||||
const statusInfo = data.executionStatus ? STATUS_INDICATORS[data.executionStatus] : null;
|
||||
const hasArtifacts = data.artifacts && data.artifacts.length > 0;
|
||||
const hasTags = data.tags && data.tags.length > 0;
|
||||
|
||||
// Resolve node theme based on phase → nodeCategory → default
|
||||
const theme = (data.phase && PHASE_THEME[data.phase])
|
||||
|| (data.nodeCategory && CATEGORY_THEME[data.nodeCategory])
|
||||
|| DEFAULT_THEME;
|
||||
|
||||
return (
|
||||
<NodeWrapper
|
||||
status={data.executionStatus}
|
||||
selected={selected}
|
||||
accentColor="blue"
|
||||
accentColor={theme.accent}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
|
||||
/>
|
||||
|
||||
{/* Node Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-blue-500 text-white rounded-t-md">
|
||||
<MessageSquare className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{data.label || 'Step'}
|
||||
</span>
|
||||
{/* Mode badge in header */}
|
||||
{data.mode && (
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', MODE_STYLES[data.mode])}>
|
||||
{data.mode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{/* Slash command badge or instruction preview */}
|
||||
{data.slashCommand ? (
|
||||
<div
|
||||
className="flex items-center gap-1.5 font-mono text-xs bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-400 px-2 py-1 rounded truncate"
|
||||
title={`/${data.slashCommand}${data.slashArgs ? ' ' + data.slashArgs : ''}`}
|
||||
>
|
||||
<Terminal className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate">/{data.slashCommand}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
|
||||
title={data.instruction}
|
||||
>
|
||||
{displayInstruction}
|
||||
</div>
|
||||
{/* Phase color strip + content layout */}
|
||||
<div className="flex">
|
||||
{/* Phase color strip (left border) */}
|
||||
{hasPhase && (
|
||||
<div className={cn('w-1 rounded-l-md flex-shrink-0', PHASE_COLORS[data.phase!])} />
|
||||
)}
|
||||
|
||||
{/* Tool and output badges row */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{/* Tool badge */}
|
||||
{data.tool && (
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded border', TOOL_STYLES[data.tool])}>
|
||||
{data.tool}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn('!w-3 !h-3 !border-2 !border-background', theme.handle)}
|
||||
/>
|
||||
|
||||
{/* Output name badge */}
|
||||
{data.outputName && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 truncate max-w-[80px]"
|
||||
title={data.outputName}
|
||||
>
|
||||
-> {data.outputName}
|
||||
{/* Node Header */}
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-white',
|
||||
theme.header,
|
||||
hasPhase ? 'rounded-tr-md' : 'rounded-t-md'
|
||||
)}>
|
||||
<MessageSquare className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{data.label || 'Step'}
|
||||
</span>
|
||||
)}
|
||||
{/* Execution status indicator */}
|
||||
{statusInfo && (
|
||||
<span className={cn('flex items-center gap-1 text-[10px] font-medium', statusInfo.color)}>
|
||||
<span className={cn('inline-block w-1.5 h-1.5 rounded-full bg-current')} />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
{/* Mode badge in header */}
|
||||
{data.mode && (
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', MODE_STYLES[data.mode])}>
|
||||
{data.mode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{/* Slash command badge or instruction preview */}
|
||||
{data.slashCommand ? (
|
||||
<div
|
||||
className="flex items-center gap-1.5 font-mono text-xs bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-400 px-2 py-1 rounded truncate"
|
||||
title={`/${data.slashCommand}${data.slashArgs ? ' ' + data.slashArgs : ''}`}
|
||||
>
|
||||
<Terminal className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate">/{data.slashCommand}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
|
||||
title={data.instruction}
|
||||
>
|
||||
{displayInstruction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags display */}
|
||||
{hasTags && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{data.tags!.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{data.tags!.length > 2 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{data.tags!.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool and output badges row */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{/* Tool badge */}
|
||||
{data.tool && (
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded border', TOOL_STYLES[data.tool])}>
|
||||
{data.tool}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Output name badge */}
|
||||
{data.outputName && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 truncate max-w-[80px]"
|
||||
title={data.outputName}
|
||||
>
|
||||
-> {data.outputName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context refs indicator */}
|
||||
{hasContextRefs && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Link2 className="w-3 h-3" />
|
||||
<span>{data.contextRefs!.length} ref{data.contextRefs!.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution error message */}
|
||||
{data.executionStatus === 'failed' && data.executionError && (
|
||||
<div
|
||||
className="text-[10px] text-destructive truncate max-w-[160px]"
|
||||
title={data.executionError}
|
||||
>
|
||||
{data.executionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artifacts display */}
|
||||
{hasArtifacts && (
|
||||
<div className="pt-0.5 border-t border-border/50 space-y-0.5">
|
||||
{data.artifacts!.map((artifact) => (
|
||||
<div
|
||||
key={artifact}
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={artifact}
|
||||
>
|
||||
→ {artifact}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn('!w-3 !h-3 !border-2 !border-background', theme.handle)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context refs indicator */}
|
||||
{hasContextRefs && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Link2 className="w-3 h-3" />
|
||||
<span>{data.contextRefs!.length} ref{data.contextRefs!.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution error message */}
|
||||
{data.executionStatus === 'failed' && data.executionError && (
|
||||
<div
|
||||
className="text-[10px] text-destructive truncate max-w-[160px]"
|
||||
title={data.executionError}
|
||||
>
|
||||
{data.executionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
|
||||
/>
|
||||
</NodeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user