feat: Implement slash command functionality in the orchestrator

- Refactored NodePalette to remove unused templates and streamline the UI.
- Enhanced PropertyPanel to support slash commands with input fields for command name and arguments.
- Introduced TagEditor for inline variable editing and custom template creation.
- Updated PromptTemplateNode to display slash command badges and instructions.
- Modified flow types to include slashCommand and slashArgs for structured execution.
- Adjusted flow executor to construct instructions based on slash command fields.
This commit is contained in:
catlog22
2026-02-05 14:29:04 +08:00
parent a19ef94444
commit 23f752b975
10 changed files with 966 additions and 252 deletions

View File

@@ -1,53 +1,99 @@
// ========================================
// Command Combobox Component
// ========================================
// Searchable dropdown for selecting slash commands
// Searchable dropdown for selecting slash commands (commands + skills)
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { ChevronDown, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCommands } from '@/hooks/useCommands';
import type { Command } from '@/lib/api';
import { useSkills } from '@/hooks/useSkills';
export interface CommandSelectDetails {
name: string;
argumentHint?: string;
description?: string;
source: 'command' | 'skill';
}
interface UnifiedItem {
name: string;
description: string;
group: string;
argumentHint?: string;
source: 'command' | 'skill';
}
interface CommandComboboxProps {
value: string;
onChange: (value: string) => void;
onSelectDetails?: (details: CommandSelectDetails) => void;
placeholder?: string;
className?: string;
}
export function CommandCombobox({ value, onChange, placeholder, className }: CommandComboboxProps) {
export function CommandCombobox({ value, onChange, onSelectDetails, placeholder, className }: CommandComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { commands, isLoading } = useCommands({
const { commands, isLoading: commandsLoading } = useCommands({
filter: { showDisabled: false },
});
// Group commands by group field
const { skills, isLoading: skillsLoading } = useSkills({
filter: { enabledOnly: true },
});
const isLoading = commandsLoading || skillsLoading;
// Merge commands and skills into unified items
const unifiedItems = useMemo<UnifiedItem[]>(() => {
const items: UnifiedItem[] = [];
for (const cmd of commands) {
items.push({
name: cmd.name,
description: cmd.description,
group: cmd.group || 'other',
argumentHint: cmd.argumentHint,
source: 'command',
});
}
for (const skill of skills) {
items.push({
name: skill.name,
description: skill.description,
group: 'skills',
source: 'skill',
});
}
return items;
}, [commands, skills]);
// Group and filter items
const groupedFiltered = useMemo(() => {
const filtered = search
? commands.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.description.toLowerCase().includes(search.toLowerCase()) ||
c.aliases?.some((a) => a.toLowerCase().includes(search.toLowerCase()))
? unifiedItems.filter(
(item) =>
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase())
)
: commands;
: unifiedItems;
const groups: Record<string, Command[]> = {};
for (const cmd of filtered) {
const group = cmd.group || 'other';
if (!groups[group]) groups[group] = [];
groups[group].push(cmd);
const groups: Record<string, UnifiedItem[]> = {};
for (const item of filtered) {
if (!groups[item.group]) groups[item.group] = [];
groups[item.group].push(item);
}
return groups;
}, [commands, search]);
}, [unifiedItems, search]);
const totalFiltered = useMemo(
() => Object.values(groupedFiltered).reduce((sum, cmds) => sum + cmds.length, 0),
() => Object.values(groupedFiltered).reduce((sum, items) => sum + items.length, 0),
[groupedFiltered]
);
@@ -65,12 +111,18 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
}, [open]);
const handleSelect = useCallback(
(name: string) => {
onChange(name);
(item: UnifiedItem) => {
onChange(item.name);
onSelectDetails?.({
name: item.name,
argumentHint: item.argumentHint,
description: item.description,
source: item.source,
});
setOpen(false);
setSearch('');
},
[onChange]
[onChange, onSelectDetails]
);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
@@ -89,11 +141,9 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
);
// Display label for current value
const selectedCommand = commands.find((c) => c.name === value);
const selectedItem = unifiedItems.find((item) => item.name === value);
const displayValue = value
? selectedCommand
? `/${selectedCommand.name}`
: `/${value}`
? `/${selectedItem?.name || value}`
: '';
return (
@@ -135,7 +185,7 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
/>
</div>
{/* Command list */}
{/* Items list */}
<div className="max-h-64 overflow-y-auto p-1">
{isLoading ? (
<div className="py-4 text-center text-sm text-muted-foreground">Loading...</div>
@@ -145,26 +195,31 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
</div>
) : (
Object.entries(groupedFiltered)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, cmds]) => (
.sort(([a], [b]) => {
// Skills group last
if (a === 'skills') return 1;
if (b === 'skills') return -1;
return a.localeCompare(b);
})
.map(([group, items]) => (
<div key={group}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{group}
</div>
{cmds.map((cmd) => (
{items.map((item) => (
<button
key={cmd.name}
key={`${item.source}-${item.name}`}
type="button"
onClick={() => handleSelect(cmd.name)}
onClick={() => handleSelect(item)}
className={cn(
'flex w-full flex-col items-start rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
value === cmd.name && 'bg-accent/50'
value === item.name && 'bg-accent/50'
)}
>
<span className="font-mono text-foreground">/{cmd.name}</span>
{cmd.description && (
<span className="font-mono text-foreground">/{item.name}</span>
{item.description && (
<span className="text-xs text-muted-foreground truncate w-full text-left">
{cmd.description}
{item.description}
</span>
)}
</button>

View File

@@ -1089,6 +1089,8 @@ export interface Command {
location?: 'project' | 'user';
path?: string;
relativePath?: string;
argumentHint?: string;
allowedTools?: string[];
}
export interface CommandsResponse {

View File

@@ -164,7 +164,10 @@
"placeholders": {
"nodeLabel": "Node label",
"instruction": "e.g., Execute /workflow:plan for login feature\nor: Analyze code architecture\nor: Save {{analysis}} to ./output/result.json",
"outputName": "e.g., analysis, plan, result"
"outputName": "e.g., analysis, plan, result",
"slashCommand": "Select a command...",
"slashArgs": "Enter arguments...",
"additionalInstruction": "Additional instructions or context for the command..."
},
"labels": {
"label": "Label",
@@ -172,7 +175,10 @@
"outputName": "Output Name",
"tool": "CLI Tool",
"mode": "Execution Mode",
"contextRefs": "Context References"
"contextRefs": "Context References",
"slashCommand": "Slash Command",
"slashArgs": "Arguments",
"additionalInstruction": "Additional Context (optional)"
},
"options": {
"toolNone": "None (auto-select)",

View File

@@ -163,7 +163,10 @@
"placeholders": {
"nodeLabel": "节点标签",
"instruction": "例如: 执行 /workflow:plan 用于登录功能\n或: 分析代码架构\n或: 将 {{analysis}} 保存到 ./output/result.json",
"outputName": "例如: analysis, plan, result"
"outputName": "例如: analysis, plan, result",
"slashCommand": "选择命令...",
"slashArgs": "输入参数...",
"additionalInstruction": "命令的附加说明或上下文..."
},
"labels": {
"label": "标签",
@@ -171,7 +174,10 @@
"outputName": "输出名称",
"tool": "CLI 工具",
"mode": "执行模式",
"contextRefs": "上下文引用"
"contextRefs": "上下文引用",
"slashCommand": "斜杠命令",
"slashArgs": "参数",
"additionalInstruction": "附加说明 (可选)"
},
"options": {
"toolNone": "无 (自动选择)",

View File

@@ -7,7 +7,7 @@ import { DragEvent, useState } from 'react';
import { useIntl } from 'react-intl';
import {
MessageSquare, ChevronDown, ChevronRight, GripVertical,
Search, Code, FileOutput, GitBranch, GitFork, GitMerge, Plus, Terminal
Search, Code, Plus, Terminal
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -26,10 +26,6 @@ const TEMPLATE_ICONS: Record<string, React.ElementType> = {
'slash-command-async': Terminal,
analysis: Search,
implementation: Code,
'file-operation': FileOutput,
conditional: GitBranch,
parallel: GitFork,
merge: GitMerge,
};
/**
@@ -212,13 +208,6 @@ export function NodePalette({ className }: NodePaletteProps) {
<QuickTemplateCard key={template.id} template={template} />
))}
</TemplateCategory>
{/* Flow Control */}
<TemplateCategory title="Flow Control" defaultExpanded={true}>
{QUICK_TEMPLATES.filter(t => ['file-operation', 'conditional', 'parallel', 'merge'].includes(t.id)).map((template) => (
<QuickTemplateCard key={template.id} template={template} />
))}
</TemplateCategory>
</div>
{/* Footer */}

View File

@@ -3,14 +3,673 @@
// ========================================
// Dynamic property editor for unified PromptTemplate nodes
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState, useEffect, useRef, KeyboardEvent } from 'react';
import { useIntl } from 'react-intl';
import { Settings, X, MessageSquare, Trash2 } from 'lucide-react';
import { Settings, X, MessageSquare, Trash2, AlertCircle, CheckCircle2, Plus, Save } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CommandCombobox, type CommandSelectDetails } from '@/components/ui/CommandCombobox';
import { useFlowStore } from '@/stores';
import type { PromptTemplateNodeData, CliTool, ExecutionMode } from '@/types/flow';
import { useCommands } from '@/hooks/useCommands';
import type { PromptTemplateNodeData, ExecutionMode } from '@/types/flow';
// ========== Tag-based Instruction Editor ==========
/**
* Built-in template definitions
*/
interface TemplateItem {
id: string;
label: string;
color: 'emerald' | 'sky' | 'amber' | 'rose' | 'violet' | 'slate' | 'cyan' | 'indigo';
content: string;
hasInput?: boolean;
inputLabel?: string;
inputDefault?: string;
isCustom?: boolean;
}
const BUILTIN_TEMPLATES: TemplateItem[] = [
// Output variable
{
id: 'output-var',
label: '输出变量',
color: 'emerald',
content: '将结果记为 {{$INPUT}} 变量,供后面节点引用。',
hasInput: true,
inputLabel: '变量名',
inputDefault: 'result',
},
// File operations
{
id: 'file-read',
label: '读取文件',
color: 'sky',
content: '读取文件 $INPUT 的内容。',
hasInput: true,
inputLabel: '文件路径',
inputDefault: './path/to/file',
},
{
id: 'file-write',
label: '写入文件',
color: 'sky',
content: '将结果写入到文件 $INPUT。',
hasInput: true,
inputLabel: '文件路径',
inputDefault: './output/result.md',
},
// Conditional
{
id: 'condition-if',
label: '条件判断',
color: 'amber',
content: '如果 $INPUT则继续执行否则停止并报告。',
hasInput: true,
inputLabel: '条件',
inputDefault: '结果成功',
},
// CLI Analysis Tools
{
id: 'cli-gemini',
label: 'Gemini分析',
color: 'cyan',
content: '使用 Gemini 分析:$INPUT\n\n分析要点\n- 代码结构和架构\n- 潜在问题和改进建议\n- 最佳实践对比',
hasInput: true,
inputLabel: '分析目标',
inputDefault: '当前模块的代码质量',
},
{
id: 'cli-codex',
label: 'Codex执行',
color: 'indigo',
content: '使用 Codex 执行:$INPUT\n\n执行要求\n- 遵循现有代码风格\n- 确保类型安全\n- 添加必要注释',
hasInput: true,
inputLabel: '执行任务',
inputDefault: '实现指定功能',
},
// Git commits
{
id: 'git-feat',
label: 'feat',
color: 'violet',
content: 'feat: $INPUT',
hasInput: true,
inputLabel: '功能描述',
inputDefault: '新功能',
},
{
id: 'git-fix',
label: 'fix',
color: 'rose',
content: 'fix: $INPUT',
hasInput: true,
inputLabel: 'Bug描述',
inputDefault: '修复问题',
},
{
id: 'git-refactor',
label: 'refactor',
color: 'slate',
content: 'refactor: $INPUT',
hasInput: true,
inputLabel: '重构描述',
inputDefault: '代码重构',
},
];
const TEMPLATE_COLORS = {
emerald: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/50 dark:text-emerald-300',
sky: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/50 dark:text-sky-300',
amber: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300',
rose: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/50 dark:text-rose-300',
violet: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/50 dark:text-violet-300',
slate: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-900/50 dark:text-slate-300',
cyan: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/50 dark:text-cyan-300',
indigo: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/50 dark:text-indigo-300',
};
const COLOR_OPTIONS: Array<{ value: TemplateItem['color']; label: string }> = [
{ value: 'emerald', label: '绿色' },
{ value: 'sky', label: '天蓝' },
{ value: 'cyan', label: '青色' },
{ value: 'indigo', label: '靛蓝' },
{ value: 'amber', label: '黄色' },
{ value: 'rose', label: '红色' },
{ value: 'violet', label: '紫色' },
{ value: 'slate', label: '灰色' },
];
// Local storage key for custom templates
const CUSTOM_TEMPLATES_KEY = 'orchestrator-custom-templates';
/**
* Load custom templates from localStorage
*/
function loadCustomTemplates(): TemplateItem[] {
try {
const stored = localStorage.getItem(CUSTOM_TEMPLATES_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* Save custom templates to localStorage
*/
function saveCustomTemplates(templates: TemplateItem[]): void {
localStorage.setItem(CUSTOM_TEMPLATES_KEY, JSON.stringify(templates));
}
// ========== Custom Template Modal ==========
interface TemplateModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (template: TemplateItem) => void;
}
function TemplateModal({ isOpen, onClose, onSave }: TemplateModalProps) {
const [label, setLabel] = useState('');
const [content, setContent] = useState('');
const [color, setColor] = useState<TemplateItem['color']>('slate');
const [hasInput, setHasInput] = useState(false);
const [inputLabel, setInputLabel] = useState('');
const [inputDefault, setInputDefault] = useState('');
const handleSave = useCallback(() => {
if (!label.trim() || !content.trim()) return;
const template: TemplateItem = {
id: `custom-${Date.now()}`,
label: label.trim(),
content: content.trim(),
color,
isCustom: true,
...(hasInput && {
hasInput: true,
inputLabel: inputLabel.trim() || '输入',
inputDefault: inputDefault.trim(),
}),
};
onSave(template);
// Reset form
setLabel('');
setContent('');
setColor('slate');
setHasInput(false);
setInputLabel('');
setInputDefault('');
onClose();
}, [label, content, color, hasInput, inputLabel, inputDefault, onSave, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Modal */}
<div className="relative bg-card border border-border rounded-lg shadow-xl w-full max-w-md mx-4 p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground"></h3>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
{/* Template name */}
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="例如:代码审查"
className="text-sm"
/>
</div>
{/* Template content */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
<span className="text-muted-foreground font-normal ml-1">(使 $INPUT )</span>
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="例如:请审查以下代码:$INPUT"
rows={3}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none"
/>
</div>
{/* Color selection */}
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setColor(opt.value)}
className={cn(
'px-3 py-1 rounded text-xs font-medium transition-all',
TEMPLATE_COLORS[opt.value],
color === opt.value && 'ring-2 ring-primary ring-offset-1'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Has input toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="has-input"
checked={hasInput}
onChange={(e) => setHasInput(e.target.checked)}
className="rounded border-border"
/>
<label htmlFor="has-input" className="text-sm text-foreground">
</label>
</div>
{/* Input configuration */}
{hasInput && (
<div className="grid grid-cols-2 gap-2 pl-6">
<div>
<label className="block text-xs text-muted-foreground mb-1"></label>
<Input
value={inputLabel}
onChange={(e) => setInputLabel(e.target.value)}
placeholder="请输入..."
className="h-8 text-xs"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1"></label>
<Input
value={inputDefault}
onChange={(e) => setInputDefault(e.target.value)}
placeholder="默认值"
className="h-8 text-xs"
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onClose}>
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!label.trim() || !content.trim()}
className="gap-1"
>
<Save className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}
interface TagEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
availableVariables: string[];
minHeight?: number;
}
/**
* Token types for the editor
*/
type TokenType = 'text' | 'variable';
interface Token {
type: TokenType;
value: string;
isValid?: boolean;
}
/**
* Parse text into tokens (text segments and variables)
*/
function tokenize(text: string): Token[] {
const tokens: Token[] = [];
const regex = /\{\{([^}]+)\}\}/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
// Add text before variable
if (match.index > lastIndex) {
tokens.push({ type: 'text', value: text.slice(lastIndex, match.index) });
}
// Add variable token
tokens.push({ type: 'variable', value: match[1].trim() });
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
tokens.push({ type: 'text', value: text.slice(lastIndex) });
}
return tokens;
}
/**
* Extract unique variable names from text
*/
function extractVariables(text: string): string[] {
const matches = text.match(/\{\{([^}]+)\}\}/g) || [];
return [...new Set(matches.map(m => m.slice(2, -2).trim()))];
}
/**
* Tag-based instruction editor with inline variable tags
*/
function TagEditor({ value, onChange, placeholder, availableVariables, minHeight = 120 }: TagEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [newVarName, setNewVarName] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [customTemplates, setCustomTemplates] = useState<TemplateItem[]>(() => loadCustomTemplates());
const tokens = useMemo(() => tokenize(value || ''), [value]);
const detectedVars = useMemo(() => extractVariables(value || ''), [value]);
const hasContent = (value || '').length > 0;
// All templates (builtin + custom)
const allTemplates = useMemo(() => [...BUILTIN_TEMPLATES, ...customTemplates], [customTemplates]);
// Save custom template
const handleSaveTemplate = useCallback((template: TemplateItem) => {
const updated = [...customTemplates, template];
setCustomTemplates(updated);
saveCustomTemplates(updated);
}, [customTemplates]);
// Delete custom template
const handleDeleteTemplate = useCallback((templateId: string) => {
const updated = customTemplates.filter(t => t.id !== templateId);
setCustomTemplates(updated);
saveCustomTemplates(updated);
}, [customTemplates]);
// Handle content changes from contenteditable
// Convert tag elements back to {{variable}} format for storage
const handleInput = useCallback(() => {
if (editorRef.current) {
// Clone the content to avoid modifying the actual DOM
const clone = editorRef.current.cloneNode(true) as HTMLElement;
// Convert variable tags back to {{variable}} format
const varTags = clone.querySelectorAll('[data-var]');
varTags.forEach((tag) => {
const varName = tag.getAttribute('data-var');
if (varName) {
tag.replaceWith(`{{${varName}}}`);
}
});
// Convert <br> to newlines
clone.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
// Get the text content
const content = clone.textContent || '';
onChange(content);
}
}, [onChange]);
// Handle paste - convert to plain text
const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}, []);
// Insert variable at cursor position
const insertVariable = useCallback((varName: string) => {
if (editorRef.current) {
editorRef.current.focus();
const varText = `{{${varName}}}`;
document.execCommand('insertText', false, varText);
}
}, []);
// Insert text at cursor position (or append if no focus)
const insertText = useCallback((text: string) => {
if (editorRef.current) {
editorRef.current.focus();
document.execCommand('insertText', false, text);
}
}, []);
// Add new variable
const handleAddVariable = useCallback(() => {
if (newVarName.trim()) {
insertVariable(newVarName.trim());
setNewVarName('');
}
}, [newVarName, insertVariable]);
// Handle key press in new variable input
const handleVarInputKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddVariable();
}
}, [handleAddVariable]);
// Render tokens as HTML - variables show as tags without {{}}
const renderContent = useMemo(() => {
if (!hasContent) return '';
return tokens.map((token) => {
if (token.type === 'variable') {
const isValid = availableVariables.includes(token.value) || token.value.includes('.');
// Show only variable name in tag, no {{}}
return `<span class="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded text-xs font-semibold align-baseline cursor-default select-none ${
isValid
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300'
}" contenteditable="false" data-var="${token.value}">${token.value}</span>`;
}
// Escape HTML in text and preserve whitespace
return token.value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
}).join('');
}, [tokens, availableVariables, hasContent]);
// Sync content when value changes externally
useEffect(() => {
if (editorRef.current && !isFocused) {
editorRef.current.innerHTML = renderContent;
}
}, [renderContent, isFocused]);
return (
<div className="space-y-2">
{/* Main editor */}
<div
className={cn(
'relative rounded-md border transition-colors',
isFocused ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
>
<div
ref={editorRef}
contentEditable
onInput={handleInput}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
data-placeholder={placeholder}
className={cn(
'w-full px-3 py-2 text-sm font-mono leading-relaxed',
'focus:outline-none',
'whitespace-pre-wrap break-words',
'[&:empty]:before:content-[attr(data-placeholder)] [&:empty]:before:text-muted-foreground'
)}
style={{ minHeight }}
dangerouslySetInnerHTML={{ __html: renderContent }}
/>
</div>
{/* Variable toolbar */}
<div className="flex flex-wrap items-center gap-2 p-2 rounded-md bg-muted/30 border border-border">
{/* Add new variable input */}
<div className="flex items-center gap-1">
<Input
value={newVarName}
onChange={(e) => setNewVarName(e.target.value)}
onKeyDown={handleVarInputKeyDown}
placeholder="变量名"
className="h-7 w-24 text-xs font-mono"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddVariable}
disabled={!newVarName.trim()}
className="h-7 px-2"
>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
<div className="w-px h-5 bg-border" />
{/* Quick insert available variables */}
{availableVariables.length > 0 && (
<>
<span className="text-xs text-muted-foreground">:</span>
{availableVariables.slice(0, 5).map((varName) => (
<button
key={varName}
type="button"
onClick={() => insertVariable(varName)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/50 dark:text-emerald-300 dark:hover:bg-emerald-900/70 transition-colors"
>
{varName}
</button>
))}
</>
)}
{/* Detected variables summary */}
{detectedVars.length > 0 && (
<>
<div className="w-px h-5 bg-border" />
<span className="text-xs text-muted-foreground">:</span>
{detectedVars.map((varName) => {
const isValid = availableVariables.includes(varName) || varName.includes('.');
return (
<span
key={varName}
className={cn(
'inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-mono',
isValid
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300'
)}
>
{isValid ? <CheckCircle2 className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
{varName}
</span>
);
})}
</>
)}
</div>
{/* Templates - categorized */}
<div className="space-y-2">
{/* Template buttons by category */}
<div className="flex flex-wrap items-center gap-2 p-2 rounded-md bg-muted/30 border border-border">
<span className="text-xs text-muted-foreground shrink-0">:</span>
{allTemplates.map((template) => (
<div key={template.id} className="relative group">
<button
type="button"
onClick={() => {
if (template.hasInput) {
const inputValue = prompt(template.inputLabel + ':', template.inputDefault);
if (inputValue !== null) {
const content = template.content.replace(/\$INPUT/g, inputValue);
insertText('\n\n' + content);
}
} else {
insertText('\n\n' + template.content);
}
}}
className={cn(
'inline-flex items-center px-2 py-1 rounded text-xs font-medium transition-colors',
TEMPLATE_COLORS[template.color]
)}
>
{template.label}
</button>
{/* Delete button for custom templates */}
{template.isCustom && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (confirm(`确定删除模板 "${template.label}"`)) {
handleDeleteTemplate(template.id);
}
}}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-destructive text-destructive-foreground items-center justify-center text-xs hidden group-hover:flex"
>
×
</button>
)}
</div>
))}
{/* Add custom template button */}
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground transition-colors"
>
<Plus className="w-3 h-3" />
</button>
</div>
</div>
{/* Custom Template Modal */}
<TemplateModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveTemplate}
/>
</div>
);
}
// ========== Form Field Components ==========
@@ -35,6 +694,98 @@ function LabelInput({ value, onChange }: LabelInputProps) {
);
}
// ========== Slash Command Section ==========
interface SlashCommandSectionProps {
data: PromptTemplateNodeData;
onChange: (updates: Partial<PromptTemplateNodeData>) => void;
availableVariables: string[];
}
function SlashCommandSection({ data, onChange, availableVariables }: SlashCommandSectionProps) {
const { formatMessage } = useIntl();
const [argumentHint, setArgumentHint] = useState<string>('');
// Look up argumentHint from loaded commands when mounting with pre-selected command
const { commands } = useCommands({ filter: { showDisabled: false } });
useEffect(() => {
if (data.slashCommand && commands.length > 0) {
const cmd = commands.find((c) => c.name === data.slashCommand);
if (cmd?.argumentHint) {
setArgumentHint(cmd.argumentHint);
}
}
}, [data.slashCommand, commands]);
const handleCommandSelect = useCallback(
(name: string) => {
const updates: Partial<PromptTemplateNodeData> = { slashCommand: name };
// Auto-set label if still default
if (!data.label || data.label === 'Slash Command' || data.label === 'Slash Command (Async)' || data.label === 'New Step') {
updates.label = `/${name}`;
}
onChange(updates);
},
[data.label, onChange]
);
const handleSelectDetails = useCallback(
(details: CommandSelectDetails) => {
setArgumentHint(details.argumentHint || '');
},
[]
);
return (
<div className="space-y-3">
{/* Slash Command Picker */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.slashCommand' })}
</label>
<CommandCombobox
value={data.slashCommand || ''}
onChange={handleCommandSelect}
onSelectDetails={handleSelectDetails}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.slashCommand' })}
/>
</div>
{/* Args Input */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.slashArgs' })}
</label>
<Input
value={data.slashArgs || ''}
onChange={(e) => onChange({ slashArgs: e.target.value })}
placeholder={argumentHint || formatMessage({ id: 'orchestrator.propertyPanel.placeholders.slashArgs' })}
className="font-mono"
/>
{argumentHint && (
<p className="text-xs text-muted-foreground mt-1 font-mono truncate" title={argumentHint}>
{argumentHint}
</p>
)}
</div>
{/* Additional instruction for context */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.additionalInstruction' })}
</label>
<TagEditor
value={data.instruction || ''}
onChange={(value) => onChange({ instruction: value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.additionalInstruction' })}
minHeight={80}
availableVariables={availableVariables}
/>
</div>
</div>
);
}
// ========== Unified PromptTemplate Property Editor ==========
interface PromptTemplatePropertiesProps {
@@ -47,6 +798,8 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
const nodes = useFlowStore((state) => state.nodes);
const selectedNodeId = useFlowStore((state) => state.selectedNodeId);
const isSlashCommandMode = data.mode === 'mainprocess' || data.mode === 'async';
// Build available outputNames from other nodes for contextRefs picker
const availableOutputNames = useMemo(() => {
return nodes
@@ -57,159 +810,63 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
}));
}, [nodes, selectedNodeId]);
const toggleContextRef = useCallback(
(outputName: string) => {
const currentRefs = data.contextRefs || [];
const newRefs = currentRefs.includes(outputName)
? currentRefs.filter((ref) => ref !== outputName)
: [...currentRefs, outputName];
onChange({ contextRefs: newRefs });
},
[data.contextRefs, onChange]
);
// Extract variable names for VariableTextarea validation
const availableVariables = useMemo(() => {
return availableOutputNames.map((n) => n.id);
}, [availableOutputNames]);
return (
<div className="space-y-4">
{/* Label */}
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
{/* Instruction - main textarea */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.instruction' })}
</label>
<textarea
value={data.instruction || ''}
onChange={(e) => onChange({ instruction: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.instruction' })}
className="w-full h-32 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
/>
</div>
{/* Output Name */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputName' })}
</label>
<Input
value={data.outputName || ''}
onChange={(e) => onChange({ outputName: e.target.value || undefined })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.outputName' })}
/>
</div>
{/* Tool Select */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.tool' })}
</label>
<select
value={data.tool || ''}
onChange={(e) => onChange({ tool: (e.target.value || undefined) as CliTool | undefined })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolNone' })}</option>
<option value="gemini">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolGemini' })}</option>
<option value="qwen">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolQwen' })}</option>
<option value="codex">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolCodex' })}</option>
<option value="claude">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolClaude' })}</option>
</select>
</div>
{/* Mode Select */}
{/* Mode Select - different options for Slash Commands vs CLI Tools */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.mode' })}
</label>
<select
value={data.mode || 'mainprocess'}
onChange={(e) => onChange({ mode: e.target.value as ExecutionMode })}
onChange={(e) => {
const newMode = e.target.value as ExecutionMode;
const updates: Partial<PromptTemplateNodeData> = { mode: newMode };
// Clear slash command fields when switching to CLI mode
if (newMode === 'analysis' || newMode === 'write') {
updates.slashCommand = '';
updates.slashArgs = '';
}
onChange(updates);
}}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="analysis">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAnalysis' })}</option>
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeWrite' })}</option>
<option value="mainprocess">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeMainprocess' })}</option>
<option value="async">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAsync' })}</option>
<optgroup label="Slash Commands">
<option value="mainprocess">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeMainprocess' })}</option>
<option value="async">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAsync' })}</option>
</optgroup>
<optgroup label="CLI Tools">
<option value="analysis">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAnalysis' })}</option>
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeWrite' })}</option>
</optgroup>
</select>
</div>
{/* Context References - multi-select */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.contextRefs' })}
</label>
<div className="space-y-2">
{/* Selected refs tags */}
{data.contextRefs && data.contextRefs.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 rounded-md border border-border bg-muted/30">
{data.contextRefs.map((ref) => {
const node = availableOutputNames.find((n) => n.id === ref);
return (
<span
key={ref}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-primary text-primary-foreground text-xs"
>
<span>{node?.label || ref}</span>
<button
type="button"
onClick={() => toggleContextRef(ref)}
className="hover:bg-primary-foreground/20 rounded p-0.5"
>
<X className="w-3 h-3" />
</button>
</span>
);
})}
<button
type="button"
onClick={() => onChange({ contextRefs: [] })}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
{formatMessage({ id: 'orchestrator.multiNodeSelector.clear' })}
</button>
</div>
)}
{/* Available outputs list */}
<div className="border border-border rounded-md bg-background max-h-32 overflow-y-auto">
{availableOutputNames.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
{formatMessage({ id: 'orchestrator.variablePicker.empty' })}
</div>
) : (
<div className="p-1">
{availableOutputNames.map((item) => {
const isSelected = data.contextRefs?.includes(item.id);
return (
<div
key={item.id}
onClick={() => toggleContextRef(item.id)}
className={cn(
'flex items-center gap-2 p-2 rounded cursor-pointer transition-colors',
'hover:bg-muted',
isSelected && 'bg-muted'
)}
>
<div
className={cn(
'w-4 h-4 rounded border flex items-center justify-center',
isSelected ? 'bg-primary border-primary' : 'border-border'
)}
>
{isSelected && <span className="text-primary-foreground text-xs">+</span>}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{item.label}</div>
<div className="text-xs text-muted-foreground font-mono">{'{{' + item.id + '}}'}</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Conditional: Slash Command Section vs Instruction textarea */}
{isSlashCommandMode ? (
<SlashCommandSection data={data} onChange={onChange} availableVariables={availableVariables} />
) : (
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.instruction' })}
</label>
<TagEditor
value={data.instruction || ''}
onChange={(value) => onChange({ instruction: value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.instruction' })}
minHeight={120}
availableVariables={availableVariables}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -7,7 +7,7 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { MessageSquare, Link2 } from 'lucide-react';
import { MessageSquare, Link2, Terminal } from 'lucide-react';
import type { PromptTemplateNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
@@ -72,13 +72,23 @@ export const PromptTemplateNode = memo(({ data, selected }: PromptTemplateNodePr
{/* Node Content */}
<div className="px-3 py-2 space-y-1.5">
{/* Instruction preview */}
<div
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
title={data.instruction}
>
{displayInstruction}
</div>
{/* 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 items-center gap-1.5 flex-wrap">

View File

@@ -95,6 +95,19 @@ export interface PromptTemplateNodeData {
*/
contextRefs?: string[];
/**
* Selected slash command name (e.g., "workflow:plan", "review-code")
* When set, overrides instruction during execution.
* Used when mode is 'mainprocess' or 'async'.
*/
slashCommand?: string;
/**
* Arguments for the slash command
* Supports {{variable}} interpolation syntax
*/
slashArgs?: string;
// ========== Execution State Fields ==========
/**
@@ -295,7 +308,9 @@ export const QUICK_TEMPLATES: QuickTemplate[] = [
color: 'bg-rose-500',
data: {
label: 'Slash Command',
instruction: 'Execute slash command:\n\n/workflow:plan "Implement [feature]"\n\nAvailable commands:\n- /workflow:plan\n- /workflow:lite-plan\n- /workflow:execute\n- /workflow:analyze-with-file\n- /workflow:brainstorm-with-file\n- /workflow:test-fix-gen',
instruction: '',
slashCommand: '',
slashArgs: '',
mode: 'mainprocess',
},
},
@@ -307,7 +322,9 @@ export const QUICK_TEMPLATES: QuickTemplate[] = [
color: 'bg-rose-400',
data: {
label: 'Slash Command (Async)',
instruction: 'Execute slash command in background:\n\n/workflow:execute --in-memory\n\nThe workflow will run asynchronously via CLI. Continue to next step without waiting.',
instruction: '',
slashCommand: '',
slashArgs: '',
mode: 'async',
},
},
@@ -337,55 +354,4 @@ export const QUICK_TEMPLATES: QuickTemplate[] = [
mode: 'write',
},
},
{
id: 'file-operation',
label: 'File Operation',
description: 'Save, read, or transform files',
icon: 'FileOutput',
color: 'bg-amber-500',
data: {
label: 'Save Output',
instruction: 'Save {{previous_output}} to ./output/result.md\n\nFormat as markdown with summary.',
mode: 'write',
contextRefs: [],
},
},
{
id: 'conditional',
label: 'Conditional',
description: 'Branch based on condition',
icon: 'GitBranch',
color: 'bg-orange-500',
data: {
label: 'Decision',
instruction: 'If {{previous.status}} === "success":\n Continue to next step\nElse:\n Stop and report error',
mode: 'mainprocess',
contextRefs: [],
},
},
{
id: 'parallel',
label: 'Parallel Start',
description: 'Fork into parallel branches',
icon: 'GitFork',
color: 'bg-cyan-500',
data: {
label: 'Parallel Tasks',
instruction: 'Execute the following tasks in parallel:\n1. [Task A]\n2. [Task B]\n3. [Task C]',
mode: 'mainprocess',
},
},
{
id: 'merge',
label: 'Merge Results',
description: 'Combine parallel outputs',
icon: 'GitMerge',
color: 'bg-pink-500',
data: {
label: 'Merge',
instruction: 'Combine results from:\n- {{task_a}}\n- {{task_b}}\n- {{task_c}}\n\nGenerate unified summary.',
mode: 'analysis',
contextRefs: [],
},
},
];

View File

@@ -107,6 +107,16 @@ export interface PromptTemplateNodeData {
*/
contextRefs?: string[];
/**
* Selected slash command name for structured execution
*/
slashCommand?: string;
/**
* Arguments for the slash command
*/
slashArgs?: string;
/**
* Error handling behavior
*/

View File

@@ -203,8 +203,21 @@ export class NodeRunner {
private async runPromptTemplate(node: FlowNode): Promise<NodeResult> {
const data = node.data as PromptTemplateNodeData;
// Interpolate instruction with variables
let instruction = interpolate(data.instruction, this.context.variables);
// Construct instruction from slash command fields if set, otherwise use raw instruction
let instruction: string;
if (data.slashCommand) {
const args = data.slashArgs
? interpolate(data.slashArgs, this.context.variables)
: '';
instruction = `/${data.slashCommand}${args ? ' ' + args : ''}`;
// Append additional instruction if provided
if (data.instruction) {
const additional = interpolate(data.instruction, this.context.variables);
instruction = `${instruction}\n\n${additional}`;
}
} else {
instruction = interpolate(data.instruction, this.context.variables);
}
// Resolve context references
if (data.contextRefs && data.contextRefs.length > 0) {