feat: 添加左侧面板和节点库组件,整合模板和节点功能

feat: 实现可折叠的模板面板,支持搜索和安装模板
feat: 更新流程工具栏,增加导入模板和模拟运行功能
feat: 增强属性面板,支持标签和产物管理
feat: 优化提示模板节点,增加执行状态和阶段显示
This commit is contained in:
catlog22
2026-02-06 11:56:48 +08:00
parent c8f9bc7994
commit 248daa1d00
9 changed files with 929 additions and 106 deletions

1
ccw/docs-site/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.ace-tool/

View File

@@ -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>
);

View 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;

View 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;

View 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;

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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}
>
-&gt; {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}
>
-&gt; {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}
>
&rarr; {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>
);
});