smartblock/web/editor.tsx
Alex Dunmow 38d5fdf4b1 Extract smartblock into a standalone wasm plugin repo
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>
2026-07-05 02:48:09 +08:00

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;