Convert the bundled backend/internal/plugins/smartblock package into a standalone reactor-mode wasm plugin: - package smartblock -> package main; add wasip1 main.go with wasmguest.Serve(Registration). - Add go.mod (module git.dev.alexdunmow.com/block/smartblock) pinning block/core v0.18.2; no replace directives. - Flesh out plugin.mod (scope=ninja, kind=plugin, categories, tags, display_name, description). - Point web/eslint.config.js at ../../../cms/web/eslint.config.js and switch @block-ninja/ui from workspace:* to the ^0.1.0 registry version; rebuild web/dist. - Add .gitignore (*.bnp, *.wasm, web/node_modules). Behavior unchanged: registers the "smart" block; assets served from web/dist. Builds to smartblock-1.0.0.bnp; check-safety passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
/**
|
|
* Smart Block Editor
|
|
*
|
|
* AI-powered block editor that generates content structure and HTML templates
|
|
* from natural language prompts.
|
|
*/
|
|
|
|
import React, { useState, useCallback, useMemo } from "react";
|
|
import {
|
|
// Types
|
|
type BlockEditorProps,
|
|
type Generation,
|
|
type SmartBlockMeta,
|
|
// Components
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
ScrollArea,
|
|
Button,
|
|
Input,
|
|
Textarea,
|
|
Label,
|
|
Badge,
|
|
Monaco,
|
|
Loader2,
|
|
Sparkles,
|
|
Check,
|
|
Code,
|
|
History,
|
|
Wand2,
|
|
Send,
|
|
Edit3,
|
|
// Hooks
|
|
useAIGenerate,
|
|
useAIAssist,
|
|
} from "@block-ninja/ui";
|
|
|
|
function SmartBlockEditor({ content, onChange }: BlockEditorProps) {
|
|
const [activeTab, setActiveTab] = useState("generate");
|
|
const [prompt, setPrompt] = useState("");
|
|
const [assistPrompt, setAssistPrompt] = useState("");
|
|
const [lastExplanation, setLastExplanation] = useState("");
|
|
|
|
const { generate, isLoading: isGenerating } = useAIGenerate();
|
|
const { assist, isLoading: isAssisting } = useAIAssist();
|
|
|
|
// Extract meta and current generation
|
|
const meta = useMemo<SmartBlockMeta>(() => {
|
|
return (content._meta as SmartBlockMeta) ?? { currentGenerationId: "", generations: [] };
|
|
}, [content._meta]);
|
|
const currentGeneration = useMemo(() => meta.generations?.find((g: Generation) => g.id === meta.currentGenerationId), [meta]);
|
|
const currentTemplate = currentGeneration?.template || "";
|
|
|
|
// Get content fields (excluding _meta)
|
|
const contentFields = useMemo(() => {
|
|
const fields: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(content)) {
|
|
if (!key.startsWith("_")) {
|
|
fields[key] = value;
|
|
}
|
|
}
|
|
return fields;
|
|
}, [content]);
|
|
|
|
// Handle generate
|
|
const handleGenerate = useCallback(async () => {
|
|
if (!prompt.trim()) return;
|
|
|
|
try {
|
|
const result = await generate(prompt, currentTemplate);
|
|
|
|
const newGeneration: Generation = {
|
|
id: result.generationId,
|
|
prompt: prompt,
|
|
schema: result.schema,
|
|
template: result.template,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
const updatedMeta: SmartBlockMeta = {
|
|
currentGenerationId: result.generationId,
|
|
generations: [...(meta.generations || []), newGeneration],
|
|
};
|
|
|
|
// Clear old content fields and add new schema defaults
|
|
const newContent: Record<string, unknown> = { _meta: updatedMeta };
|
|
if (result.schema?.properties) {
|
|
for (const key of Object.keys(result.schema.properties as Record<string, unknown>)) {
|
|
newContent[key] = "";
|
|
}
|
|
}
|
|
|
|
onChange(newContent);
|
|
setLastExplanation(result.explanation);
|
|
setPrompt("");
|
|
setActiveTab("content");
|
|
} catch (error) {
|
|
console.error("Generation failed:", error);
|
|
}
|
|
}, [prompt, currentTemplate, meta, generate, onChange]);
|
|
|
|
// Handle assist
|
|
const handleAssist = useCallback(async () => {
|
|
if (!assistPrompt.trim() || !currentTemplate) return;
|
|
|
|
try {
|
|
const result = await assist(assistPrompt, currentTemplate, currentGeneration?.schema ? JSON.stringify(currentGeneration.schema) : undefined);
|
|
|
|
// Update the current generation's template
|
|
const updatedGenerations = (meta.generations || []).map((g: Generation) => (g.id === meta.currentGenerationId ? { ...g, template: result.template } : g));
|
|
|
|
onChange({
|
|
...content,
|
|
_meta: { ...meta, generations: updatedGenerations },
|
|
});
|
|
|
|
setLastExplanation(result.explanation);
|
|
setAssistPrompt("");
|
|
} catch (error) {
|
|
console.error("Assist failed:", error);
|
|
}
|
|
}, [assistPrompt, currentTemplate, currentGeneration, meta, content, assist, onChange]);
|
|
|
|
// Handle template change (direct edit)
|
|
const handleTemplateChange = useCallback(
|
|
(newTemplate: string | undefined) => {
|
|
if (!newTemplate || !currentGeneration) return;
|
|
|
|
const updatedGenerations = (meta.generations || []).map((g: Generation) => (g.id === meta.currentGenerationId ? { ...g, template: newTemplate } : g));
|
|
|
|
onChange({
|
|
...content,
|
|
_meta: { ...meta, generations: updatedGenerations },
|
|
});
|
|
},
|
|
[currentGeneration, meta, content, onChange],
|
|
);
|
|
|
|
// Handle switching generation
|
|
const handleSwitchGeneration = useCallback(
|
|
(generationId: string) => {
|
|
const generation = meta.generations?.find((g: Generation) => g.id === generationId);
|
|
if (!generation) return;
|
|
|
|
// Clear current content and set defaults from new schema
|
|
const newContent: Record<string, unknown> = {
|
|
_meta: { ...meta, currentGenerationId: generationId },
|
|
};
|
|
|
|
if (generation.schema?.properties) {
|
|
for (const key of Object.keys(generation.schema.properties as Record<string, unknown>)) {
|
|
newContent[key] = content[key] ?? "";
|
|
}
|
|
}
|
|
|
|
onChange(newContent);
|
|
setActiveTab("content");
|
|
},
|
|
[meta, content, onChange],
|
|
);
|
|
|
|
// Handle content field change
|
|
const handleFieldChange = useCallback(
|
|
(fieldName: string, value: unknown) => {
|
|
onChange({ ...content, [fieldName]: value });
|
|
},
|
|
[content, onChange],
|
|
);
|
|
|
|
// Render content fields based on schema
|
|
const renderContentFields = () => {
|
|
const schema = currentGeneration?.schema as Record<string, unknown> | undefined;
|
|
if (!schema?.properties) {
|
|
return (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<p>Generate content structure first</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const properties = Object.entries(schema.properties as Record<string, unknown>);
|
|
const requiredFields = new Set((schema.required as string[]) || []);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Content Fields</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{properties.map(([name, prop]) => {
|
|
const propObj = prop as Record<string, unknown>;
|
|
const isRequired = requiredFields.has(name);
|
|
const editor = (propObj["x-editor"] as string) || "text";
|
|
let inputType = "text";
|
|
if (editor === "number") {
|
|
inputType = "number";
|
|
} else if (editor === "url") {
|
|
inputType = "url";
|
|
}
|
|
|
|
return (
|
|
<div key={name} className="space-y-2">
|
|
<Label htmlFor={name}>
|
|
{(propObj.title as string) || (propObj.description as string) || name}
|
|
{isRequired && <span className="text-destructive ml-1">*</span>}
|
|
</Label>
|
|
{editor === "textarea" ?
|
|
<Textarea
|
|
id={name}
|
|
value={(contentFields[name] as string) || ""}
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => handleFieldChange(name, e.target.value)}
|
|
placeholder={propObj.description as string}
|
|
rows={4}
|
|
/>
|
|
: <Input
|
|
id={name}
|
|
type={inputType}
|
|
value={(contentFields[name] as string) || ""}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleFieldChange(name, e.target.value)}
|
|
placeholder={propObj.description as string}
|
|
/>
|
|
}
|
|
</div>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="grid w-full grid-cols-4">
|
|
<TabsTrigger value="generate" className="gap-2">
|
|
<Wand2 className="h-4 w-4" />
|
|
Generate
|
|
</TabsTrigger>
|
|
<TabsTrigger value="content" className="gap-2" disabled={!currentGeneration}>
|
|
<Edit3 className="h-4 w-4" />
|
|
Content
|
|
</TabsTrigger>
|
|
<TabsTrigger value="html" className="gap-2" disabled={!currentGeneration}>
|
|
<Code className="h-4 w-4" />
|
|
HTML
|
|
</TabsTrigger>
|
|
<TabsTrigger value="history" className="gap-2" disabled={(meta.generations?.length || 0) === 0}>
|
|
<History className="h-4 w-4" />
|
|
History
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Generate Tab */}
|
|
<TabsContent value="generate" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-primary" />
|
|
Generate Content Structure
|
|
</CardTitle>
|
|
<CardDescription>Describe the content you want to create. AI will generate both the form fields and HTML template.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Textarea
|
|
placeholder="E.g., Create a testimonial card with author photo, quote, name, title, and 5-star rating..."
|
|
value={prompt}
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setPrompt(e.target.value)}
|
|
className="min-h-[100px]"
|
|
/>
|
|
<div className="flex justify-between items-center">
|
|
<p className="text-sm text-muted-foreground">{currentGeneration ? "Generating will create a new version" : "Start by describing your content"}</p>
|
|
<Button action="create" entity="smart-block-generation" onClick={handleGenerate} disabled={isGenerating || !prompt.trim()}>
|
|
{isGenerating ?
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Generating...
|
|
</>
|
|
: <>
|
|
<Wand2 className="h-4 w-4 mr-2" />
|
|
Generate
|
|
</>
|
|
}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{lastExplanation && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">AI Response</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground">{lastExplanation}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!currentGeneration && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<Wand2 className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
<p>No content structure generated yet</p>
|
|
<p className="text-sm">Enter a prompt above to get started</p>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Content Tab */}
|
|
<TabsContent value="content">{renderContentFields()}</TabsContent>
|
|
|
|
{/* HTML Tab */}
|
|
<TabsContent value="html" className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm flex items-center justify-between">
|
|
HTML Template
|
|
<Badge variant="outline">{Object.keys(((currentGeneration?.schema as Record<string, unknown>)?.properties as Record<string, unknown>) || {}).length} fields</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Monaco value={currentTemplate} onChange={handleTemplateChange} language="html" height="300px" theme="vs-dark" />
|
|
<p className="text-xs text-muted-foreground mt-2">Use {"{{fieldName}}"} placeholders to insert content values</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4" />
|
|
AI Assistant
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="E.g., Make the quote italic and add a shadow..."
|
|
value={assistPrompt}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAssistPrompt(e.target.value)}
|
|
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleAssist();
|
|
}
|
|
}}
|
|
/>
|
|
<Button action="edit" entity="smart-block-template" onClick={handleAssist} disabled={isAssisting || !assistPrompt.trim()} size="icon">
|
|
{isAssisting ?
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
: <Send className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* History Tab */}
|
|
<TabsContent value="history">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm">Generation History</CardTitle>
|
|
<CardDescription>Switch between previous generations or regenerate from a prompt</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScrollArea className="h-[300px]">
|
|
<div className="space-y-2">
|
|
{[...(meta.generations || [])].reverse().map((gen: Generation) => (
|
|
<Button
|
|
key={gen.id}
|
|
type="button"
|
|
variant="ghost"
|
|
action="select"
|
|
entity="smart-block-generation"
|
|
className={`w-full h-auto justify-start p-3 rounded-lg border text-left font-normal whitespace-normal transition-colors hover:text-foreground [&_svg]:size-3 ${
|
|
gen.id === meta.currentGenerationId ? "bg-primary/10 border-primary hover:bg-primary/10" : "hover:bg-muted"
|
|
}`}
|
|
onClick={() => handleSwitchGeneration(gen.id)}>
|
|
<div className="w-full flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{gen.prompt}</p>
|
|
<p className="text-xs text-muted-foreground">{new Date(gen.createdAt).toLocaleString()}</p>
|
|
</div>
|
|
{gen.id === meta.currentGenerationId && (
|
|
<Badge variant="default" className="shrink-0">
|
|
<Check className="h-3 w-3 mr-1" />
|
|
Active
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Export for plugin loader
|
|
export default SmartBlockEditor;
|