diff --git a/src/components/marketplace/CreateTemplateModal.tsx b/src/components/marketplace/CreateTemplateModal.tsx new file mode 100644 index 0000000..de17ba5 --- /dev/null +++ b/src/components/marketplace/CreateTemplateModal.tsx @@ -0,0 +1,790 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm + +import { useState, useMemo } from 'react' +import { + Loader2, + Bot, + Users2, + Clock, + Variable, + ChevronRight, + ChevronLeft, + CheckCircle2, + Plus, + X, + LayoutTemplate, + Sparkles, +} from 'lucide-react' +import { SlidePanel } from '@/components/shared/SlidePanel' +import { useAgents } from '@/hooks/useAgents' +import { useTeams } from '@/hooks/useTeams' +import { useWorkspace } from '@/hooks/useWorkspace' +import { useCreateTemplate } from '@/hooks/useWorkflowTemplates' +import type { PipelineConfig, TemplateVariable, TemplateAgentDef, TemplateTeamDef, TemplateTriggerDef, TemplateDefinition, Team } from '@/types' +import { cn } from '@/lib/utils' + +interface CreateTemplateModalProps { + open: boolean + onClose: () => void + /** Pre-select specific agents */ + preSelectedAgentIds?: string[] + /** Pre-select a specific team */ + preSelectedTeamId?: string | null +} + +type Step = 'agents' | 'variables' | 'metadata' | 'preview' + +const STEP_ORDER: Step[] = ['agents', 'variables', 'metadata', 'preview'] + +const CATEGORY_OPTIONS = [ + { value: 'coaching', label: '๐Ÿ‰ Coaching' }, + { value: 'research', label: '๐Ÿ”ฌ Research' }, + { value: 'content', label: '๐Ÿ“ Content' }, + { value: 'devops', label: 'โš™๏ธ DevOps' }, + { value: 'reporting', label: '๐Ÿ“Š Reporting' }, + { value: 'sales', label: '๐Ÿ’ผ Sales & Marketing' }, + { value: 'support', label: '๐ŸŽง Support' }, + { value: 'general', label: '๐Ÿค– General' }, +] + +const ICON_OPTIONS = ['๐Ÿค–', '๐Ÿ‰', '๐Ÿ“', '๐Ÿ“ฐ', '๐Ÿ”', '๐Ÿ“Š', '๐Ÿ’ผ', '๐ŸŽง', 'โš™๏ธ', '๐Ÿ”ฌ', '๐Ÿง ', '๐Ÿ“‹', '๐ŸŽฏ', '๐Ÿ’ก', '๐Ÿš€', '๐ŸŒŸ'] + +/** + * Scan all string values in the template definition for {{variable}} patterns. + * Returns unique variable keys found. + */ +function scanForVariables(agents: TemplateAgentDef[], team: TemplateTeamDef | null, trigger: TemplateTriggerDef | null): string[] { + const json = JSON.stringify({ agents, team, trigger }) + const matches = json.match(/\{\{(\w+)\}\}/g) + if (!matches) return [] + const keys = matches.map((m) => m.replace(/\{\{|\}\}/g, '')) + return [...new Set(keys)] +} + +export function CreateTemplateModal({ open, onClose, preSelectedAgentIds, preSelectedTeamId }: CreateTemplateModalProps) { + const { workspaceId } = useWorkspace() + const { agents: workspaceAgents } = useAgents(workspaceId ?? null) + const { teams: workspaceTeams } = useTeams(workspaceId ?? null) + const createMutation = useCreateTemplate() + + // โ”€โ”€โ”€ Step State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [step, setStep] = useState('agents') + const [published, setPublished] = useState(false) + + // โ”€โ”€โ”€ Step 1: Agent & Team Selection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [selectedAgentIds, setSelectedAgentIds] = useState(preSelectedAgentIds ?? []) + const [selectedTeamId, setSelectedTeamId] = useState(preSelectedTeamId ?? null) + + const selectedAgents = useMemo( + () => workspaceAgents.filter((a) => selectedAgentIds.includes(a.id)), + [workspaceAgents, selectedAgentIds], + ) + const selectedTeam: Team | null = useMemo( + () => workspaceTeams.find((t) => t.id === selectedTeamId) ?? null, + [workspaceTeams, selectedTeamId], + ) + + // โ”€โ”€โ”€ Step 2: Variables โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [variables, setVariables] = useState([]) + + // โ”€โ”€โ”€ Step 3: Metadata โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [category, setCategory] = useState('general') + const [icon, setIcon] = useState('๐Ÿค–') + const [readme, setReadme] = useState('') + const [tags, setTags] = useState([]) + const [tagInput, setTagInput] = useState('') + + // Trigger (optional) + const [enableTrigger, setEnableTrigger] = useState(false) + const [triggerType, setTriggerType] = useState<'cron' | 'webhook'>('cron') + const [cronExpression, setCronExpression] = useState('0 9 * * 5') + const [taskTitleTemplate, setTaskTitleTemplate] = useState('') + const [taskDescTemplate, setTaskDescTemplate] = useState('') + + const [isPublished, setIsPublished] = useState(true) + + // โ”€โ”€โ”€ Build Template Definition โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const buildAgentDefs = (): TemplateAgentDef[] => + selectedAgents.map((a) => ({ + name: a.name, + description: a.description, + system_prompt: a.system_prompt, + model: a.model, + provider: a.provider ?? 'openai', + temperature: a.temperature, + max_tokens: a.max_tokens ?? null, + tags: Array.isArray(a.tags) ? a.tags : [], + tools: a.tools, + })) + + const buildTeamDef = (): TemplateTeamDef | null => { + if (!selectedTeam) return null + const config = selectedTeam.config as PipelineConfig + return { + name: selectedTeam.name, + description: selectedTeam.description, + mode: selectedTeam.mode, + steps: config.steps.map((s) => ({ + agent_index: selectedAgentIds.indexOf(s.agent_id), + step_name: s.step_name, + instructions: s.instructions, + expected_output: s.expected_output, + })), + } + } + + const buildTriggerDef = (): TemplateTriggerDef | null => { + if (!enableTrigger) return null + return { + type: triggerType, + cron_expression: triggerType === 'cron' ? cronExpression : '', + task_title_template: taskTitleTemplate, + task_description_template: taskDescTemplate, + } + } + + // โ”€โ”€โ”€ Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const stepIdx = STEP_ORDER.indexOf(step) + + const canAdvance = (): boolean => { + switch (step) { + case 'agents': + return selectedAgentIds.length > 0 + case 'variables': + return true + case 'metadata': + return name.trim().length > 0 && description.trim().length > 0 + default: + return false + } + } + + const handleNext = () => { + if (step === 'agents') { + // Auto-scan variables when moving to variables step + const agentDefs = buildAgentDefs() + const teamDef = buildTeamDef() + const triggerDef = buildTriggerDef() + const foundKeys = scanForVariables(agentDefs, teamDef, triggerDef) + + // Preserve any existing variable definitions, add new ones + const existingKeys = new Set(variables.map((v) => v.key)) + const newVars: TemplateVariable[] = foundKeys + .filter((k) => !existingKeys.has(k)) + .map((key) => ({ + key, + label: key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' '), + type: 'text' as const, + placeholder: '', + required: true, + default: '', + })) + + setVariables([...variables, ...newVars]) + } + + const nextIdx = stepIdx + 1 + if (nextIdx < STEP_ORDER.length) { + setStep(STEP_ORDER[nextIdx]) + } + } + + const handleBack = () => { + const prevIdx = stepIdx - 1 + if (prevIdx >= 0) { + setStep(STEP_ORDER[prevIdx]) + } + } + + const handlePublish = () => { + if (!workspaceId) return + + const definition: TemplateDefinition = { + agents: buildAgentDefs(), + team: buildTeamDef(), + trigger: buildTriggerDef(), + } + + createMutation.mutate( + { + workspace_id: workspaceId, + name, + description, + readme: readme || undefined, + category, + tags, + icon, + template_definition: definition, + variables, + is_published: isPublished, + }, + { onSuccess: () => setPublished(true) }, + ) + } + + // โ”€โ”€โ”€ Toggle helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const toggleAgent = (id: string) => { + setSelectedAgentIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ) + } + + const addTag = () => { + const t = tagInput.trim().toLowerCase() + if (t && !tags.includes(t)) setTags([...tags, t]) + setTagInput('') + } + + const updateVariable = (idx: number, updates: Partial) => { + setVariables((prev) => + prev.map((v, i) => (i === idx ? { ...v, ...updates } : v)), + ) + } + + const removeVariable = (idx: number) => { + setVariables((prev) => prev.filter((_, i) => i !== idx)) + } + + const addVariable = () => { + setVariables([ + ...variables, + { key: '', label: '', type: 'text', placeholder: '', required: true, default: '' }, + ]) + } + + if (!open) return null + + // โ”€โ”€โ”€ Success State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (published) { + return ( + +
+ +

Template Created!

+

+ Your template "{name}" is now {isPublished ? 'live in the marketplace' : 'saved as a draft'}. +

+ +
+
+ ) + } + + return ( + + {stepIdx > 0 && ( + + )} +
+ {step === 'preview' ? ( + + ) : ( + + )} +
+ } + > + {/* Header */} +
+
+ +

Create Template

+
+ {/* Progress */} +
+ {STEP_ORDER.map((s, i) => ( +
+ ))} +
+

+ Step {stepIdx + 1} of {STEP_ORDER.length} + {' โ€” '} + {step === 'agents' && 'Select agents & team'} + {step === 'variables' && 'Define template variables'} + {step === 'metadata' && 'Add metadata & trigger'} + {step === 'preview' && 'Review & publish'} +

+
+ + {/* Error */} + {createMutation.isError && ( +
+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create template.'} +
+ )} + + {/* โ”€โ”€โ”€ Step 1: Select Agents & Team โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {step === 'agents' && ( +
+
+

+ Select Agents +

+

+ Choose which agents to include in this template. Their prompts will become the blueprint. +

+
+ {workspaceAgents.map((agent) => { + const selected = selectedAgentIds.includes(agent.id) + return ( + + ) + })} +
+
+ + {/* Team Selection */} + {workspaceTeams.length > 0 && ( +
+

+ Include Team (optional) +

+
+ + {workspaceTeams.map((team) => { + const selected = selectedTeamId === team.id + return ( + + ) + })} +
+
+ )} +
+ )} + + {/* โ”€โ”€โ”€ Step 2: Variables โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {step === 'variables' && ( +
+
+

+ + Template Variables +

+

+ Variables are placeholders in your agent prompts using {'{{variable_name}}'} syntax. + Users fill these in when installing your template. +

+
+ + {variables.length === 0 ? ( +
+ +

+ No variables detected. Add {'{{variable}}'} to your agent prompts, + or add custom variables below. +

+
+ ) : ( +
+ {variables.map((v, idx) => ( +
+
+ + {`{{${v.key || '...'}}}`} + + +
+
+ updateVariable(idx, { key: e.target.value.replace(/\s+/g, '_').toLowerCase() })} + placeholder="variable_key" + className="rounded-md border border-border bg-surface-card px-3 py-1.5 font-mono text-xs text-gray-200 outline-none focus:border-brand-primary" + /> + updateVariable(idx, { label: e.target.value })} + placeholder="Display Label" + className="rounded-md border border-border bg-surface-card px-3 py-1.5 text-xs text-gray-200 outline-none focus:border-brand-primary" + /> + updateVariable(idx, { placeholder: e.target.value })} + placeholder="Placeholder text" + className="rounded-md border border-border bg-surface-card px-3 py-1.5 text-xs text-gray-200 outline-none focus:border-brand-primary" + /> + updateVariable(idx, { default: e.target.value })} + placeholder="Default value" + className="rounded-md border border-border bg-surface-card px-3 py-1.5 text-xs text-gray-200 outline-none focus:border-brand-primary" + /> +
+ +
+ ))} +
+ )} + + +
+ )} + + {/* โ”€โ”€โ”€ Step 3: Metadata โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {step === 'metadata' && ( +
+ {/* Icon + Name */} +
+
+ +
+ {ICON_OPTIONS.map((ic) => ( + + ))} +
+
+
+ + setName(e.target.value)} + placeholder="e.g. Weekly Sports Coach" + className="w-full rounded-lg border border-border bg-surface-card px-4 py-2.5 text-sm text-gray-200 placeholder-gray-500 outline-none focus:border-brand-primary" + /> +
+
+ + {/* Description */} +
+ +