From 23f752b9756b8ea7b50289bfef953e43bb9dcd04 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 5 Feb 2026 14:29:04 +0800 Subject: [PATCH] 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. --- .../src/components/ui/CommandCombobox.tsx | 125 ++- ccw/frontend/src/lib/api.ts | 2 + ccw/frontend/src/locales/en/orchestrator.json | 10 +- ccw/frontend/src/locales/zh/orchestrator.json | 10 +- .../src/pages/orchestrator/NodePalette.tsx | 13 +- .../src/pages/orchestrator/PropertyPanel.tsx | 933 +++++++++++++++--- .../orchestrator/nodes/PromptTemplateNode.tsx | 26 +- ccw/frontend/src/types/flow.ts | 72 +- ccw/src/core/routes/orchestrator-routes.ts | 10 + ccw/src/core/services/flow-executor.ts | 17 +- 10 files changed, 966 insertions(+), 252 deletions(-) diff --git a/ccw/frontend/src/components/ui/CommandCombobox.tsx b/ccw/frontend/src/components/ui/CommandCombobox.tsx index bd947685..3d7964e4 100644 --- a/ccw/frontend/src/components/ui/CommandCombobox.tsx +++ b/ccw/frontend/src/components/ui/CommandCombobox.tsx @@ -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(null); const inputRef = useRef(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(() => { + 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 = {}; - for (const cmd of filtered) { - const group = cmd.group || 'other'; - if (!groups[group]) groups[group] = []; - groups[group].push(cmd); + const groups: Record = {}; + 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) => { @@ -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 /> - {/* Command list */} + {/* Items list */}
{isLoading ? (
Loading...
@@ -145,26 +195,31 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
) : ( 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]) => (
{group}
- {cmds.map((cmd) => ( + {items.map((item) => ( diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 77176571..1d65f255 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -1089,6 +1089,8 @@ export interface Command { location?: 'project' | 'user'; path?: string; relativePath?: string; + argumentHint?: string; + allowedTools?: string[]; } export interface CommandsResponse { diff --git a/ccw/frontend/src/locales/en/orchestrator.json b/ccw/frontend/src/locales/en/orchestrator.json index d611bc20..e977fc6a 100644 --- a/ccw/frontend/src/locales/en/orchestrator.json +++ b/ccw/frontend/src/locales/en/orchestrator.json @@ -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)", diff --git a/ccw/frontend/src/locales/zh/orchestrator.json b/ccw/frontend/src/locales/zh/orchestrator.json index 01c8f007..c3c776ae 100644 --- a/ccw/frontend/src/locales/zh/orchestrator.json +++ b/ccw/frontend/src/locales/zh/orchestrator.json @@ -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": "无 (自动选择)", diff --git a/ccw/frontend/src/pages/orchestrator/NodePalette.tsx b/ccw/frontend/src/pages/orchestrator/NodePalette.tsx index 3c120280..d278a3ec 100644 --- a/ccw/frontend/src/pages/orchestrator/NodePalette.tsx +++ b/ccw/frontend/src/pages/orchestrator/NodePalette.tsx @@ -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 = { '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) { ))} - - {/* Flow Control */} - - {QUICK_TEMPLATES.filter(t => ['file-operation', 'conditional', 'parallel', 'merge'].includes(t.id)).map((template) => ( - - ))} -
{/* Footer */} diff --git a/ccw/frontend/src/pages/orchestrator/PropertyPanel.tsx b/ccw/frontend/src/pages/orchestrator/PropertyPanel.tsx index 30f537d1..2963245f 100644 --- a/ccw/frontend/src/pages/orchestrator/PropertyPanel.tsx +++ b/ccw/frontend/src/pages/orchestrator/PropertyPanel.tsx @@ -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('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 ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+
+

创建自定义模板

+ +
+ + {/* Template name */} +
+ + setLabel(e.target.value)} + placeholder="例如:代码审查" + className="text-sm" + /> +
+ + {/* Template content */} +
+ +