mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-08 02:14:08 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -1089,6 +1089,8 @@ export interface Command {
|
||||
location?: 'project' | 'user';
|
||||
path?: string;
|
||||
relativePath?: string;
|
||||
argumentHint?: string;
|
||||
allowedTools?: string[];
|
||||
}
|
||||
|
||||
export interface CommandsResponse {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "无 (自动选择)",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user