mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +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
|
// 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 { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { ChevronDown, Search } from 'lucide-react';
|
import { ChevronDown, Search } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useCommands } from '@/hooks/useCommands';
|
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 {
|
interface CommandComboboxProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
onSelectDetails?: (details: CommandSelectDetails) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: 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 [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { commands, isLoading } = useCommands({
|
const { commands, isLoading: commandsLoading } = useCommands({
|
||||||
filter: { showDisabled: false },
|
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 groupedFiltered = useMemo(() => {
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? commands.filter(
|
? unifiedItems.filter(
|
||||||
(c) =>
|
(item) =>
|
||||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
item.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
c.description.toLowerCase().includes(search.toLowerCase()) ||
|
item.description.toLowerCase().includes(search.toLowerCase())
|
||||||
c.aliases?.some((a) => a.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
)
|
)
|
||||||
: commands;
|
: unifiedItems;
|
||||||
|
|
||||||
const groups: Record<string, Command[]> = {};
|
const groups: Record<string, UnifiedItem[]> = {};
|
||||||
for (const cmd of filtered) {
|
for (const item of filtered) {
|
||||||
const group = cmd.group || 'other';
|
if (!groups[item.group]) groups[item.group] = [];
|
||||||
if (!groups[group]) groups[group] = [];
|
groups[item.group].push(item);
|
||||||
groups[group].push(cmd);
|
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}, [commands, search]);
|
}, [unifiedItems, search]);
|
||||||
|
|
||||||
const totalFiltered = useMemo(
|
const totalFiltered = useMemo(
|
||||||
() => Object.values(groupedFiltered).reduce((sum, cmds) => sum + cmds.length, 0),
|
() => Object.values(groupedFiltered).reduce((sum, items) => sum + items.length, 0),
|
||||||
[groupedFiltered]
|
[groupedFiltered]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,12 +111,18 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(name: string) => {
|
(item: UnifiedItem) => {
|
||||||
onChange(name);
|
onChange(item.name);
|
||||||
|
onSelectDetails?.({
|
||||||
|
name: item.name,
|
||||||
|
argumentHint: item.argumentHint,
|
||||||
|
description: item.description,
|
||||||
|
source: item.source,
|
||||||
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
},
|
},
|
||||||
[onChange]
|
[onChange, onSelectDetails]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -89,11 +141,9 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Display label for current value
|
// Display label for current value
|
||||||
const selectedCommand = commands.find((c) => c.name === value);
|
const selectedItem = unifiedItems.find((item) => item.name === value);
|
||||||
const displayValue = value
|
const displayValue = value
|
||||||
? selectedCommand
|
? `/${selectedItem?.name || value}`
|
||||||
? `/${selectedCommand.name}`
|
|
||||||
: `/${value}`
|
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -135,7 +185,7 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Command list */}
|
{/* Items list */}
|
||||||
<div className="max-h-64 overflow-y-auto p-1">
|
<div className="max-h-64 overflow-y-auto p-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-4 text-center text-sm text-muted-foreground">Loading...</div>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(groupedFiltered)
|
Object.entries(groupedFiltered)
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => {
|
||||||
.map(([group, cmds]) => (
|
// Skills group last
|
||||||
|
if (a === 'skills') return 1;
|
||||||
|
if (b === 'skills') return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
})
|
||||||
|
.map(([group, items]) => (
|
||||||
<div key={group}>
|
<div key={group}>
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
{group}
|
{group}
|
||||||
</div>
|
</div>
|
||||||
{cmds.map((cmd) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={cmd.name}
|
key={`${item.source}-${item.name}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(cmd.name)}
|
onClick={() => handleSelect(item)}
|
||||||
className={cn(
|
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',
|
'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>
|
<span className="font-mono text-foreground">/{item.name}</span>
|
||||||
{cmd.description && (
|
{item.description && (
|
||||||
<span className="text-xs text-muted-foreground truncate w-full text-left">
|
<span className="text-xs text-muted-foreground truncate w-full text-left">
|
||||||
{cmd.description}
|
{item.description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1089,6 +1089,8 @@ export interface Command {
|
|||||||
location?: 'project' | 'user';
|
location?: 'project' | 'user';
|
||||||
path?: string;
|
path?: string;
|
||||||
relativePath?: string;
|
relativePath?: string;
|
||||||
|
argumentHint?: string;
|
||||||
|
allowedTools?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandsResponse {
|
export interface CommandsResponse {
|
||||||
|
|||||||
@@ -164,7 +164,10 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"nodeLabel": "Node label",
|
"nodeLabel": "Node label",
|
||||||
"instruction": "e.g., Execute /workflow:plan for login feature\nor: Analyze code architecture\nor: Save {{analysis}} to ./output/result.json",
|
"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": {
|
"labels": {
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
@@ -172,7 +175,10 @@
|
|||||||
"outputName": "Output Name",
|
"outputName": "Output Name",
|
||||||
"tool": "CLI Tool",
|
"tool": "CLI Tool",
|
||||||
"mode": "Execution Mode",
|
"mode": "Execution Mode",
|
||||||
"contextRefs": "Context References"
|
"contextRefs": "Context References",
|
||||||
|
"slashCommand": "Slash Command",
|
||||||
|
"slashArgs": "Arguments",
|
||||||
|
"additionalInstruction": "Additional Context (optional)"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"toolNone": "None (auto-select)",
|
"toolNone": "None (auto-select)",
|
||||||
|
|||||||
@@ -163,7 +163,10 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"nodeLabel": "节点标签",
|
"nodeLabel": "节点标签",
|
||||||
"instruction": "例如: 执行 /workflow:plan 用于登录功能\n或: 分析代码架构\n或: 将 {{analysis}} 保存到 ./output/result.json",
|
"instruction": "例如: 执行 /workflow:plan 用于登录功能\n或: 分析代码架构\n或: 将 {{analysis}} 保存到 ./output/result.json",
|
||||||
"outputName": "例如: analysis, plan, result"
|
"outputName": "例如: analysis, plan, result",
|
||||||
|
"slashCommand": "选择命令...",
|
||||||
|
"slashArgs": "输入参数...",
|
||||||
|
"additionalInstruction": "命令的附加说明或上下文..."
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"label": "标签",
|
"label": "标签",
|
||||||
@@ -171,7 +174,10 @@
|
|||||||
"outputName": "输出名称",
|
"outputName": "输出名称",
|
||||||
"tool": "CLI 工具",
|
"tool": "CLI 工具",
|
||||||
"mode": "执行模式",
|
"mode": "执行模式",
|
||||||
"contextRefs": "上下文引用"
|
"contextRefs": "上下文引用",
|
||||||
|
"slashCommand": "斜杠命令",
|
||||||
|
"slashArgs": "参数",
|
||||||
|
"additionalInstruction": "附加说明 (可选)"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"toolNone": "无 (自动选择)",
|
"toolNone": "无 (自动选择)",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { DragEvent, useState } from 'react';
|
|||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
MessageSquare, ChevronDown, ChevronRight, GripVertical,
|
MessageSquare, ChevronDown, ChevronRight, GripVertical,
|
||||||
Search, Code, FileOutput, GitBranch, GitFork, GitMerge, Plus, Terminal
|
Search, Code, Plus, Terminal
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -26,10 +26,6 @@ const TEMPLATE_ICONS: Record<string, React.ElementType> = {
|
|||||||
'slash-command-async': Terminal,
|
'slash-command-async': Terminal,
|
||||||
analysis: Search,
|
analysis: Search,
|
||||||
implementation: Code,
|
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} />
|
<QuickTemplateCard key={template.id} template={template} />
|
||||||
))}
|
))}
|
||||||
</TemplateCategory>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -3,14 +3,673 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// Dynamic property editor for unified PromptTemplate nodes
|
// 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 { 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 { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { CommandCombobox, type CommandSelectDetails } from '@/components/ui/CommandCombobox';
|
||||||
import { useFlowStore } from '@/stores';
|
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 ==========
|
// ========== 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 ==========
|
// ========== Unified PromptTemplate Property Editor ==========
|
||||||
|
|
||||||
interface PromptTemplatePropertiesProps {
|
interface PromptTemplatePropertiesProps {
|
||||||
@@ -47,6 +798,8 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
|
|||||||
const nodes = useFlowStore((state) => state.nodes);
|
const nodes = useFlowStore((state) => state.nodes);
|
||||||
const selectedNodeId = useFlowStore((state) => state.selectedNodeId);
|
const selectedNodeId = useFlowStore((state) => state.selectedNodeId);
|
||||||
|
|
||||||
|
const isSlashCommandMode = data.mode === 'mainprocess' || data.mode === 'async';
|
||||||
|
|
||||||
// Build available outputNames from other nodes for contextRefs picker
|
// Build available outputNames from other nodes for contextRefs picker
|
||||||
const availableOutputNames = useMemo(() => {
|
const availableOutputNames = useMemo(() => {
|
||||||
return nodes
|
return nodes
|
||||||
@@ -57,159 +810,63 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
|
|||||||
}));
|
}));
|
||||||
}, [nodes, selectedNodeId]);
|
}, [nodes, selectedNodeId]);
|
||||||
|
|
||||||
const toggleContextRef = useCallback(
|
// Extract variable names for VariableTextarea validation
|
||||||
(outputName: string) => {
|
const availableVariables = useMemo(() => {
|
||||||
const currentRefs = data.contextRefs || [];
|
return availableOutputNames.map((n) => n.id);
|
||||||
const newRefs = currentRefs.includes(outputName)
|
}, [availableOutputNames]);
|
||||||
? currentRefs.filter((ref) => ref !== outputName)
|
|
||||||
: [...currentRefs, outputName];
|
|
||||||
onChange({ contextRefs: newRefs });
|
|
||||||
},
|
|
||||||
[data.contextRefs, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
|
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
|
||||||
|
|
||||||
{/* Instruction - main textarea */}
|
{/* 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.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 */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
{formatMessage({ id: 'orchestrator.propertyPanel.labels.mode' })}
|
{formatMessage({ id: 'orchestrator.propertyPanel.labels.mode' })}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={data.mode || 'mainprocess'}
|
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"
|
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>
|
<optgroup label="Slash Commands">
|
||||||
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeWrite' })}</option>
|
<option value="mainprocess">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeMainprocess' })}</option>
|
||||||
<option value="mainprocess">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeMainprocess' })}</option>
|
<option value="async">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAsync' })}</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context References - multi-select */}
|
{/* Conditional: Slash Command Section vs Instruction textarea */}
|
||||||
<div>
|
{isSlashCommandMode ? (
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">
|
<SlashCommandSection data={data} onChange={onChange} availableVariables={availableVariables} />
|
||||||
{formatMessage({ id: 'orchestrator.propertyPanel.labels.contextRefs' })}
|
) : (
|
||||||
</label>
|
<div>
|
||||||
<div className="space-y-2">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
{/* Selected refs tags */}
|
{formatMessage({ id: 'orchestrator.propertyPanel.labels.instruction' })}
|
||||||
{data.contextRefs && data.contextRefs.length > 0 && (
|
</label>
|
||||||
<div className="flex flex-wrap gap-2 p-2 rounded-md border border-border bg-muted/30">
|
<TagEditor
|
||||||
{data.contextRefs.map((ref) => {
|
value={data.instruction || ''}
|
||||||
const node = availableOutputNames.find((n) => n.id === ref);
|
onChange={(value) => onChange({ instruction: value })}
|
||||||
return (
|
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.instruction' })}
|
||||||
<span
|
minHeight={120}
|
||||||
key={ref}
|
availableVariables={availableVariables}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Handle, Position } from '@xyflow/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 type { PromptTemplateNodeData } from '@/types/flow';
|
||||||
import { NodeWrapper } from './NodeWrapper';
|
import { NodeWrapper } from './NodeWrapper';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -72,13 +72,23 @@ export const PromptTemplateNode = memo(({ data, selected }: PromptTemplateNodePr
|
|||||||
|
|
||||||
{/* Node Content */}
|
{/* Node Content */}
|
||||||
<div className="px-3 py-2 space-y-1.5">
|
<div className="px-3 py-2 space-y-1.5">
|
||||||
{/* Instruction preview */}
|
{/* Slash command badge or instruction preview */}
|
||||||
<div
|
{data.slashCommand ? (
|
||||||
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
|
<div
|
||||||
title={data.instruction}
|
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 : ''}`}
|
||||||
{displayInstruction}
|
>
|
||||||
</div>
|
<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 */}
|
{/* Tool and output badges row */}
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
|||||||
@@ -95,6 +95,19 @@ export interface PromptTemplateNodeData {
|
|||||||
*/
|
*/
|
||||||
contextRefs?: string[];
|
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 ==========
|
// ========== Execution State Fields ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,7 +308,9 @@ export const QUICK_TEMPLATES: QuickTemplate[] = [
|
|||||||
color: 'bg-rose-500',
|
color: 'bg-rose-500',
|
||||||
data: {
|
data: {
|
||||||
label: 'Slash Command',
|
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',
|
mode: 'mainprocess',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -307,7 +322,9 @@ export const QUICK_TEMPLATES: QuickTemplate[] = [
|
|||||||
color: 'bg-rose-400',
|
color: 'bg-rose-400',
|
||||||
data: {
|
data: {
|
||||||
label: 'Slash Command (Async)',
|
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',
|
mode: 'async',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -337,55 +354,4 @@ export const QUICK_TEMPLATES: QuickTemplate[] = [
|
|||||||
mode: 'write',
|
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[];
|
contextRefs?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selected slash command name for structured execution
|
||||||
|
*/
|
||||||
|
slashCommand?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arguments for the slash command
|
||||||
|
*/
|
||||||
|
slashArgs?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error handling behavior
|
* Error handling behavior
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -203,8 +203,21 @@ export class NodeRunner {
|
|||||||
private async runPromptTemplate(node: FlowNode): Promise<NodeResult> {
|
private async runPromptTemplate(node: FlowNode): Promise<NodeResult> {
|
||||||
const data = node.data as PromptTemplateNodeData;
|
const data = node.data as PromptTemplateNodeData;
|
||||||
|
|
||||||
// Interpolate instruction with variables
|
// Construct instruction from slash command fields if set, otherwise use raw instruction
|
||||||
let instruction = interpolate(data.instruction, this.context.variables);
|
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
|
// Resolve context references
|
||||||
if (data.contextRefs && data.contextRefs.length > 0) {
|
if (data.contextRefs && data.contextRefs.length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user