Spaces:
Configuration error
Configuration error
| "use client" | |
| import { useState, useCallback, useMemo } from "react" | |
| import { useUser } from "@stackframe/stack" | |
| import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" | |
| import { Button } from "@/components/ui/button" | |
| import { Input, Textarea } from "@/components/ui/input" | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from "@/components/ui/select" | |
| import { Badge } from "@/components/ui/badge" | |
| import { extractVariables, generateSlug } from "@/lib/utils" | |
| import { VariableSchema, PromptSchema, AI_MODELS, ModelId, DEFAULT_MODEL } from "@/types/prompt" | |
| import { useOllama } from "@/contexts/ollama-context" | |
| import { FrameworkSelector } from "@/components/create/framework-selector" | |
| import { FrameworkScaffold } from "@/components/create/framework-scaffold" | |
| import { PromptCoach } from "@/components/editor/prompt-coach" | |
| import { getFramework, generateFromFramework, Framework } from "@/lib/frameworks" | |
| import { | |
| Sparkles, | |
| Play, | |
| Save, | |
| Eye, | |
| Code, | |
| Plus, | |
| Trash2, | |
| GripVertical, | |
| ChevronDown, | |
| AlertCircle, | |
| Target | |
| } from "lucide-react" | |
| import { cn } from "@/lib/utils" | |
| type VariableType = "text" | "textarea" | "dropdown" | "number" | "boolean" | |
| const VARIABLE_TYPES: { value: VariableType; label: string }[] = [ | |
| { value: "text", label: "Text Input" }, | |
| { value: "textarea", label: "Text Area" }, | |
| { value: "dropdown", label: "Dropdown" }, | |
| { value: "number", label: "Number" }, | |
| { value: "boolean", label: "Checkbox" }, | |
| ] | |
| const CATEGORIES = [ | |
| "Content", | |
| "Development", | |
| "Marketing", | |
| "Business", | |
| "Education", | |
| "Creative", | |
| "Research", | |
| "Other", | |
| ] | |
| interface EditorState { | |
| title: string | |
| description: string | |
| template: string | |
| category: string | |
| tags: string | |
| modelDefault: string | |
| visibility: "public" | "unlisted" | "private" | |
| } | |
| interface PromptEditorProps { | |
| initialData?: { | |
| title: string | |
| description: string | null | |
| template: string | |
| category: string | null | |
| tags: string[] | |
| modelDefault: string | |
| visibility: string | |
| schema: PromptSchema | null | |
| } | |
| promptSlug?: string // If provided, we're editing an existing prompt | |
| } | |
| export function PromptEditor({ initialData, promptSlug }: PromptEditorProps = {}) { | |
| const user = useUser() | |
| const { settings: ollamaSettings } = useOllama() | |
| const [state, setState] = useState<EditorState>({ | |
| title: initialData?.title || "", | |
| description: initialData?.description || "", | |
| template: initialData?.template || "", | |
| category: initialData?.category || "", | |
| tags: initialData?.tags?.join(", ") || "", | |
| modelDefault: initialData?.modelDefault || DEFAULT_MODEL, | |
| visibility: (initialData?.visibility as EditorState["visibility"]) || "public", | |
| }) | |
| // Initialize variables from initialData if editing | |
| const initialVariables = initialData?.schema?.variables || [] | |
| const [variables, setVariables] = useState<VariableSchema[]>(initialVariables) | |
| const [isPreview, setIsPreview] = useState(false) | |
| const [isSaving, setIsSaving] = useState(false) | |
| const [error, setError] = useState<string | null>(null) | |
| // Framework Engine state | |
| const [selectedFramework, setSelectedFramework] = useState<string | null>(null) | |
| const [frameworkSections, setFrameworkSections] = useState<Record<string, string>>({}) | |
| const [showFrameworkSelector, setShowFrameworkSelector] = useState(true) | |
| // Get the selected framework object | |
| const currentFramework = useMemo(() => { | |
| return selectedFramework ? getFramework(selectedFramework) : null | |
| }, [selectedFramework]) | |
| // Handle framework selection | |
| const handleFrameworkSelect = (frameworkId: string | null) => { | |
| setSelectedFramework(frameworkId) | |
| setFrameworkSections({}) | |
| if (!frameworkId) { | |
| // Clear template if deselecting framework | |
| setShowFrameworkSelector(false) | |
| } else { | |
| setShowFrameworkSelector(false) | |
| } | |
| } | |
| // Generate template from framework sections | |
| const handleGenerateFromFramework = useCallback(() => { | |
| if (currentFramework) { | |
| const generatedTemplate = generateFromFramework(currentFramework, frameworkSections) | |
| setState(s => ({ ...s, template: generatedTemplate })) | |
| } | |
| }, [currentFramework, frameworkSections]) | |
| // Auto-detect variables from template | |
| const detectedVariables = useMemo(() => { | |
| return extractVariables(state.template) | |
| }, [state.template]) | |
| // Sync detected variables with current variable configs | |
| const handleDetectVariables = useCallback(() => { | |
| const newVariables = detectedVariables.map((name) => { | |
| // Keep existing config if variable already exists | |
| const existing = variables.find((v) => v.name === name) | |
| if (existing) return existing | |
| return { | |
| name, | |
| type: "text" as const, | |
| label: name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, " "), | |
| placeholder: `Enter ${name.replace(/_/g, " ")}...`, | |
| required: true, | |
| } | |
| }) | |
| setVariables(newVariables) | |
| }, [detectedVariables, variables]) | |
| // Update a variable config | |
| const updateVariable = (index: number, updates: Partial<VariableSchema>) => { | |
| setVariables((prev) => | |
| prev.map((v, i) => (i === index ? { ...v, ...updates } : v)) | |
| ) | |
| } | |
| // Handle form submission | |
| const handleSave = async (publish: boolean = false) => { | |
| setError(null) | |
| if (!state.title.trim()) { | |
| setError("Title is required") | |
| return | |
| } | |
| if (!state.template.trim()) { | |
| setError("Prompt template is required") | |
| return | |
| } | |
| setIsSaving(true) | |
| try { | |
| const schema: PromptSchema = { | |
| variables, | |
| output: { | |
| format: "markdown", | |
| streaming: true, | |
| }, | |
| } | |
| const isEditing = !!promptSlug | |
| const url = isEditing ? `/api/prompts/${promptSlug}` : "/api/prompts" | |
| const method = isEditing ? "PUT" : "POST" | |
| const response = await fetch(url, { | |
| method, | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| title: state.title, | |
| description: state.description, | |
| template: state.template, | |
| schema, | |
| category: state.category || null, | |
| tags: state.tags.split(",").map((t) => t.trim()).filter(Boolean), | |
| modelDefault: state.modelDefault, | |
| visibility: publish ? state.visibility : "private", | |
| ...(isEditing ? {} : {}), // creatorId is now handled server-side via auth | |
| }), | |
| }) | |
| if (!response.ok) { | |
| throw new Error(isEditing ? "Failed to update prompt" : "Failed to save prompt") | |
| } | |
| const data = await response.json() | |
| // Redirect to the prompt page | |
| window.location.href = `/p/${data.slug || promptSlug}` | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : "Failed to save prompt") | |
| } finally { | |
| setIsSaving(false) | |
| } | |
| } | |
| return ( | |
| <div className="grid lg:grid-cols-2 gap-6"> | |
| {/* Left Pane - Template Editor */} | |
| <div className="space-y-4"> | |
| {/* Framework Selector */} | |
| {showFrameworkSelector && ( | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <FrameworkSelector | |
| selectedFramework={selectedFramework ?? undefined} | |
| onSelect={handleFrameworkSelect} | |
| /> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* Framework Scaffold - when a framework is selected */} | |
| {currentFramework && !showFrameworkSelector && ( | |
| <Card> | |
| <CardHeader className="pb-3"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <Target className="h-5 w-5 text-primary" /> | |
| <CardTitle className="text-base">Using {currentFramework.name} Framework</CardTitle> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setShowFrameworkSelector(true)} | |
| > | |
| Change | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => { | |
| setSelectedFramework(null) | |
| setFrameworkSections({}) | |
| }} | |
| > | |
| Clear | |
| </Button> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <FrameworkScaffold | |
| framework={currentFramework} | |
| sections={frameworkSections} | |
| onSectionsChange={setFrameworkSections} | |
| /> | |
| <Button | |
| className="w-full gap-2" | |
| onClick={handleGenerateFromFramework} | |
| disabled={Object.keys(frameworkSections).length === 0} | |
| > | |
| <Sparkles className="h-4 w-4" /> | |
| Generate Template from Framework | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* Button to show framework selector if hidden and no framework selected */} | |
| {!showFrameworkSelector && !currentFramework && ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setShowFrameworkSelector(true)} | |
| className="gap-2" | |
| > | |
| <Target className="h-4 w-4" /> | |
| Use a Framework | |
| </Button> | |
| )} | |
| <Card> | |
| <CardHeader> | |
| <div className="flex items-center justify-between"> | |
| <CardTitle className="flex items-center gap-2"> | |
| <Code className="h-5 w-5" /> | |
| Prompt Template | |
| </CardTitle> | |
| {selectedFramework && ( | |
| <Badge variant="secondary" className="gap-1"> | |
| <Target className="h-3 w-3" /> | |
| {selectedFramework} | |
| </Badge> | |
| )} | |
| </div> | |
| <CardDescription> | |
| Use {"{{variable_name}}"} syntax to create input fields | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <Input | |
| placeholder="Prompt Title" | |
| value={state.title} | |
| onChange={(e) => setState((s) => ({ ...s, title: e.target.value }))} | |
| className="text-lg font-medium" | |
| /> | |
| <Textarea | |
| placeholder="Brief description of what this prompt does..." | |
| value={state.description} | |
| onChange={(e) => setState((s) => ({ ...s, description: e.target.value }))} | |
| className="min-h-20" | |
| /> | |
| <Textarea | |
| placeholder={`Write your prompt here... | |
| Example: | |
| Write a blog post about {{topic}} in a {{tone}} tone. | |
| The post should be {{length}} words and include: | |
| - An engaging introduction | |
| - 3 main points | |
| - A compelling conclusion`} | |
| value={state.template} | |
| onChange={(e) => setState((s) => ({ ...s, template: e.target.value }))} | |
| className="min-h-75 font-mono text-sm" | |
| /> | |
| {/* AI Prompt Coach */} | |
| <PromptCoach | |
| prompt={state.template} | |
| onApply={(improved) => setState((s) => ({ ...s, template: improved }))} | |
| /> | |
| {detectedVariables.length > 0 && ( | |
| <div className="flex items-center justify-between p-3 bg-muted rounded-lg"> | |
| <span className="text-sm"> | |
| Detected {detectedVariables.length} variable{detectedVariables.length !== 1 ? "s" : ""}:{" "} | |
| {detectedVariables.map((v, i) => ( | |
| <span key={v}> | |
| <code className="prompt-variable">{`{{${v}}}`}</code> | |
| {i < detectedVariables.length - 1 ? ", " : ""} | |
| </span> | |
| ))} | |
| </span> | |
| <Button size="sm" variant="secondary" onClick={handleDetectVariables}> | |
| <Sparkles className="h-3.5 w-3.5 mr-1.5" /> | |
| Configure | |
| </Button> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Settings Card */} | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="text-base">Settings</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="text-sm font-medium mb-1.5 block">Category</label> | |
| <Select | |
| value={state.category} | |
| onValueChange={(val) => setState((s) => ({ ...s, category: val }))} | |
| > | |
| <SelectTrigger> | |
| <SelectValue placeholder="Select category..." /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {CATEGORIES.map((cat) => ( | |
| <SelectItem key={cat} value={cat}>{cat}</SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium mb-1.5 block">Default Model</label> | |
| <Select | |
| value={state.modelDefault} | |
| onValueChange={(val) => setState((s) => ({ ...s, modelDefault: val }))} | |
| > | |
| <SelectTrigger> | |
| <SelectValue placeholder="Select model..." /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectGroup> | |
| <SelectLabel className="flex items-center gap-1">☁️ Cloud Models</SelectLabel> | |
| {Object.entries(AI_MODELS).map(([id, model]) => ( | |
| <SelectItem key={id} value={id}>{model.name}</SelectItem> | |
| ))} | |
| </SelectGroup> | |
| {ollamaSettings.enabled && ollamaSettings.availableModels.length > 0 && ( | |
| <SelectGroup> | |
| <SelectLabel className="flex items-center gap-1 mt-2 border-t pt-2">🖥️ Ollama (Local)</SelectLabel> | |
| {ollamaSettings.availableModels.map((model) => ( | |
| <SelectItem key={model} value={model}>{model}</SelectItem> | |
| ))} | |
| </SelectGroup> | |
| )} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium mb-1.5 block">Tags (comma-separated)</label> | |
| <Input | |
| placeholder="e.g., blog, writing, seo" | |
| value={state.tags} | |
| onChange={(e) => setState((s) => ({ ...s, tags: e.target.value }))} | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium mb-1.5 block">Visibility</label> | |
| <div className="flex gap-2"> | |
| {(["public", "unlisted", "private"] as const).map((v) => ( | |
| <Button | |
| key={v} | |
| variant={state.visibility === v ? "default" : "outline"} | |
| size="sm" | |
| onClick={() => setState((s) => ({ ...s, visibility: v }))} | |
| className="capitalize" | |
| > | |
| {v} | |
| </Button> | |
| ))} | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| {/* Right Pane - Variable Schema */} | |
| <div className="space-y-4"> | |
| <Card> | |
| <CardHeader> | |
| <div className="flex items-center justify-between"> | |
| <CardTitle className="flex items-center gap-2"> | |
| <Sparkles className="h-5 w-5" /> | |
| Variable Configuration | |
| </CardTitle> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setIsPreview(!isPreview)} | |
| className="gap-1.5" | |
| > | |
| <Eye className="h-4 w-4" /> | |
| {isPreview ? "Edit" : "Preview"} | |
| </Button> | |
| </div> | |
| <CardDescription> | |
| Configure how each variable appears to users | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| {variables.length === 0 ? ( | |
| <div className="text-center py-8 text-muted-foreground"> | |
| <Sparkles className="h-12 w-12 mx-auto mb-4 opacity-20" /> | |
| <p className="mb-2">No variables configured</p> | |
| <p className="text-sm"> | |
| Add {"{{variables}}"} to your template and click "Configure" | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-4"> | |
| {variables.map((variable, index) => ( | |
| <Card key={variable.name} className="border-dashed"> | |
| <CardContent className="p-4 space-y-3"> | |
| <div className="flex items-center gap-2"> | |
| <GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" /> | |
| <code className="prompt-variable text-xs"> | |
| {`{{${variable.name}}}`} | |
| </code> | |
| <Badge variant="outline" className="capitalize text-xs ml-auto"> | |
| {variable.type} | |
| </Badge> | |
| </div> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium mb-1 block">Label</label> | |
| <Input | |
| value={variable.label} | |
| onChange={(e) => updateVariable(index, { label: e.target.value })} | |
| placeholder="Field label" | |
| className="h-8 text-sm" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium mb-1 block">Type</label> | |
| <select | |
| value={variable.type} | |
| onChange={(e) => updateVariable(index, { | |
| type: e.target.value as VariableType | |
| })} | |
| className="flex h-8 w-full rounded-lg border border-border bg-background px-2 text-sm" | |
| > | |
| {VARIABLE_TYPES.map((t) => ( | |
| <option key={t.value} value={t.value}>{t.label}</option> | |
| ))} | |
| </select> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium mb-1 block">Placeholder</label> | |
| <Input | |
| value={variable.placeholder || ""} | |
| onChange={(e) => updateVariable(index, { placeholder: e.target.value })} | |
| placeholder="Placeholder text..." | |
| className="h-8 text-sm" | |
| /> | |
| </div> | |
| {variable.type === "dropdown" && ( | |
| <div> | |
| <label className="text-xs font-medium mb-1 block"> | |
| Options (comma-separated) | |
| </label> | |
| <Input | |
| value={variable.options?.join(", ") || ""} | |
| onChange={(e) => updateVariable(index, { | |
| options: e.target.value.split(",").map((s) => s.trim()) | |
| })} | |
| placeholder="Option 1, Option 2, Option 3" | |
| className="h-8 text-sm" | |
| /> | |
| </div> | |
| )} | |
| <div className="flex items-center gap-4"> | |
| <label className="flex items-center gap-2 cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| checked={variable.required} | |
| onChange={(e) => updateVariable(index, { required: e.target.checked })} | |
| className="h-3.5 w-3.5 rounded" | |
| /> | |
| <span className="text-xs">Required</span> | |
| </label> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ))} | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Error Display */} | |
| {error && ( | |
| <Card className="border-destructive"> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <AlertCircle className="h-5 w-5 text-destructive" /> | |
| <p className="text-sm text-destructive">{error}</p> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* Action Buttons */} | |
| <div className="flex gap-3"> | |
| <Button | |
| variant="outline" | |
| className="flex-1 gap-2" | |
| onClick={() => handleSave(false)} | |
| disabled={isSaving} | |
| > | |
| <Save className="h-4 w-4" /> | |
| Save Draft | |
| </Button> | |
| <Button | |
| className="flex-1 gap-2" | |
| onClick={() => handleSave(true)} | |
| disabled={isSaving} | |
| > | |
| <Play className="h-4 w-4" /> | |
| {isSaving ? "Publishing..." : "Publish"} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |