mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +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
|
// 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 { useState, useCallback, useEffect } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
Plus,
|
|
||||||
Save,
|
Save,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Download,
|
Download,
|
||||||
@@ -16,6 +15,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Library,
|
Library,
|
||||||
|
Play,
|
||||||
|
FlaskConical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -39,7 +40,6 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
|||||||
const isModified = useFlowStore((state) => state.isModified);
|
const isModified = useFlowStore((state) => state.isModified);
|
||||||
const flows = useFlowStore((state) => state.flows);
|
const flows = useFlowStore((state) => state.flows);
|
||||||
const isLoadingFlows = useFlowStore((state) => state.isLoadingFlows);
|
const isLoadingFlows = useFlowStore((state) => state.isLoadingFlows);
|
||||||
const createFlow = useFlowStore((state) => state.createFlow);
|
|
||||||
const saveFlow = useFlowStore((state) => state.saveFlow);
|
const saveFlow = useFlowStore((state) => state.saveFlow);
|
||||||
const loadFlow = useFlowStore((state) => state.loadFlow);
|
const loadFlow = useFlowStore((state) => state.loadFlow);
|
||||||
const deleteFlow = useFlowStore((state) => state.deleteFlow);
|
const deleteFlow = useFlowStore((state) => state.deleteFlow);
|
||||||
@@ -56,13 +56,6 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
|||||||
setFlowName(currentFlow?.name || '');
|
setFlowName(currentFlow?.name || '');
|
||||||
}, [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
|
// Handle save
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!currentFlow) {
|
if (!currentFlow) {
|
||||||
@@ -186,11 +179,7 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={handleNew}>
|
{/* Save & Load Group */}
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
New
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -290,17 +279,31 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={!currentFlow}>
|
<div className="w-px h-6 bg-border" />
|
||||||
<Download className="w-4 h-4 mr-1" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
|
{/* Import & Export Group */}
|
||||||
<Button variant="outline" size="sm" onClick={onOpenTemplateLibrary}>
|
<Button variant="outline" size="sm" onClick={onOpenTemplateLibrary}>
|
||||||
<Library className="w-4 h-4 mr-1" />
|
<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>
|
</Button>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
<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>
|
||||||
</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 { useEffect, useState, useCallback } from 'react';
|
||||||
import { useFlowStore } from '@/stores';
|
import { useFlowStore } from '@/stores';
|
||||||
import { FlowCanvas } from './FlowCanvas';
|
import { FlowCanvas } from './FlowCanvas';
|
||||||
import { NodePalette } from './NodePalette';
|
import { LeftSidebar } from './LeftSidebar';
|
||||||
import { PropertyPanel } from './PropertyPanel';
|
import { PropertyPanel } from './PropertyPanel';
|
||||||
import { FlowToolbar } from './FlowToolbar';
|
import { FlowToolbar } from './FlowToolbar';
|
||||||
import { TemplateLibrary } from './TemplateLibrary';
|
import { TemplateLibrary } from './TemplateLibrary';
|
||||||
@@ -32,8 +32,8 @@ export function OrchestratorPage() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Node Palette (Left) */}
|
{/* Left Sidebar (Templates + Nodes) */}
|
||||||
<NodePalette />
|
<LeftSidebar />
|
||||||
|
|
||||||
{/* Flow Canvas (Center) */}
|
{/* Flow Canvas (Center) */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo, useState, useEffect, useRef, KeyboardEvent } from 'react';
|
import { useCallback, useMemo, useState, useEffect, useRef, KeyboardEvent } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
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 { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
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 ==========
|
// ========== Unified PromptTemplate Property Editor ==========
|
||||||
|
|
||||||
interface PromptTemplatePropertiesProps {
|
interface PromptTemplatePropertiesProps {
|
||||||
@@ -867,6 +1037,75 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface NodeWrapperProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
status?: ExecutionStatus;
|
status?: ExecutionStatus;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
accentColor: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +35,11 @@ const SELECTION_STYLES: Record<string, string> = {
|
|||||||
green: 'ring-2 ring-green-500/20 border-green-500',
|
green: 'ring-2 ring-green-500/20 border-green-500',
|
||||||
amber: 'ring-2 ring-amber-500/20 border-amber-500',
|
amber: 'ring-2 ring-amber-500/20 border-amber-500',
|
||||||
purple: 'ring-2 ring-purple-500/20 border-purple-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
|
// 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',
|
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) => {
|
export const PromptTemplateNode = memo(({ data, selected }: PromptTemplateNodeProps) => {
|
||||||
// Truncate instruction for display (max 50 chars)
|
// Truncate instruction for display (max 50 chars)
|
||||||
const displayInstruction = data.instruction
|
const displayInstruction = data.instruction
|
||||||
@@ -42,99 +76,163 @@ export const PromptTemplateNode = memo(({ data, selected }: PromptTemplateNodePr
|
|||||||
: 'No instruction';
|
: 'No instruction';
|
||||||
|
|
||||||
const hasContextRefs = data.contextRefs && data.contextRefs.length > 0;
|
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 (
|
return (
|
||||||
<NodeWrapper
|
<NodeWrapper
|
||||||
status={data.executionStatus}
|
status={data.executionStatus}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
accentColor="blue"
|
accentColor={theme.accent}
|
||||||
>
|
>
|
||||||
{/* Input Handle */}
|
{/* Phase color strip + content layout */}
|
||||||
<Handle
|
<div className="flex">
|
||||||
type="target"
|
{/* Phase color strip (left border) */}
|
||||||
position={Position.Top}
|
{hasPhase && (
|
||||||
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
|
<div className={cn('w-1 rounded-l-md flex-shrink-0', PHASE_COLORS[data.phase!])} />
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tool and output badges row */}
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
{/* Input Handle */}
|
||||||
{/* Tool badge */}
|
<Handle
|
||||||
{data.tool && (
|
type="target"
|
||||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded border', TOOL_STYLES[data.tool])}>
|
position={Position.Top}
|
||||||
{data.tool}
|
className={cn('!w-3 !h-3 !border-2 !border-background', theme.handle)}
|
||||||
</span>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Output name badge */}
|
{/* Node Header */}
|
||||||
{data.outputName && (
|
<div className={cn(
|
||||||
<span
|
'flex items-center gap-2 px-3 py-2 text-white',
|
||||||
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]"
|
theme.header,
|
||||||
title={data.outputName}
|
hasPhase ? 'rounded-tr-md' : 'rounded-t-md'
|
||||||
>
|
)}>
|
||||||
-> {data.outputName}
|
<MessageSquare className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate flex-1">
|
||||||
|
{data.label || 'Step'}
|
||||||
</span>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
|
|
||||||
/>
|
|
||||||
</NodeWrapper>
|
</NodeWrapper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user