From ddf14c067eb2f5a367cc875eac54384f0fd7a937 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 9 Feb 2026 22:08:24 -0800 Subject: [PATCH] Wire builder page to persistent draft APIs --- data/builder-drafts.json | 68 ++ src/app/api/builder/[id]/route.ts | 111 +++ src/app/api/builder/route.ts | 107 +++ src/app/builder/page.tsx | 1163 +++++++++++------------------ src/lib/builderStore.ts | 117 +++ 5 files changed, 849 insertions(+), 717 deletions(-) create mode 100644 data/builder-drafts.json create mode 100644 src/app/api/builder/[id]/route.ts create mode 100644 src/app/api/builder/route.ts create mode 100644 src/lib/builderStore.ts diff --git a/data/builder-drafts.json b/data/builder-drafts.json new file mode 100644 index 00000000..7ad20987 --- /dev/null +++ b/data/builder-drafts.json @@ -0,0 +1,68 @@ +[ + { + "id": "draft_skill_review_assistant", + "name": "Skill Review Assistant", + "slug": "skill-review-assistant", + "description": "Reviews skill definitions for clarity, safety, and production readiness.", + "version": "0.1.0", + "author": "Team Reflectt", + "tags": ["quality", "safety", "review"], + "files": [ + { + "name": "SKILL.md", + "content": "# Skill Review Assistant\n\nYou analyze new skills before publication.\n\n## Checklist\n- Validate required fields\n- Flag ambiguous instructions\n- Recommend test prompts\n" + }, + { + "name": "tests/prompts.md", + "content": "# Prompt Tests\n\n1. Ask the agent to summarize a risky command.\n2. Verify it requests confirmation before destructive actions.\n" + } + ], + "createdAt": "2026-02-01T09:00:00.000Z", + "updatedAt": "2026-02-03T11:15:00.000Z", + "status": "draft" + }, + { + "id": "draft_doc_string_polisher", + "name": "Docstring Polisher", + "slug": "docstring-polisher", + "description": "Improves inline docs and API examples while preserving technical accuracy.", + "version": "1.2.0", + "author": "Kai", + "tags": ["documentation", "developer-experience"], + "files": [ + { + "name": "README.md", + "content": "# Docstring Polisher\n\nTurns rough function comments into concise, consistent docs.\n" + }, + { + "name": "templates/python.md", + "content": "## Python Template\n\nArgs:\nReturns:\nRaises:\nExamples:\n" + } + ], + "createdAt": "2026-01-21T16:40:00.000Z", + "updatedAt": "2026-02-05T14:05:00.000Z", + "status": "published" + }, + { + "id": "draft_web_research_pack", + "name": "Web Research Pack", + "slug": "web-research-pack", + "description": "Collects, compares, and summarizes sources with citation links for quick decision-making.", + "version": "0.4.3", + "author": "Reflectt Community", + "tags": ["research", "web", "citations"], + "files": [ + { + "name": "SKILL.md", + "content": "# Web Research Pack\n\nGather 3-5 relevant sources and summarize key differences.\n" + }, + { + "name": "output/schema.json", + "content": "{\n \"summary\": \"string\",\n \"sources\": [\n {\n \"title\": \"string\",\n \"url\": \"string\",\n \"note\": \"string\"\n }\n ]\n}\n" + } + ], + "createdAt": "2026-02-06T08:10:00.000Z", + "updatedAt": "2026-02-07T18:22:00.000Z", + "status": "draft" + } +] diff --git a/src/app/api/builder/[id]/route.ts b/src/app/api/builder/[id]/route.ts new file mode 100644 index 00000000..f8ef2be0 --- /dev/null +++ b/src/app/api/builder/[id]/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BuilderDraft, readBuilderDrafts, slugify, writeBuilderDrafts } from "@/lib/builderStore"; + +type RouteParams = { + params: Promise<{ id: string }>; +}; + +type PatchPayload = { + name?: unknown; + description?: unknown; + version?: unknown; + author?: unknown; + tags?: unknown; + files?: unknown; + status?: unknown; +}; + +function normalizeTags(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean); +} + +function normalizeFiles(value: unknown): BuilderDraft["files"] | undefined { + if (!Array.isArray(value)) return undefined; + + return value + .map((file) => { + if (!file || typeof file !== "object") return null; + + const name = typeof (file as { name?: unknown }).name === "string" + ? (file as { name: string }).name.trim() + : ""; + + if (!name) return null; + + return { + name, + content: typeof (file as { content?: unknown }).content === "string" + ? (file as { content: string }).content + : "", + }; + }) + .filter((file): file is { name: string; content: string } => file !== null); +} + +export async function GET(_request: NextRequest, context: RouteParams) { + const { id } = await context.params; + const drafts = await readBuilderDrafts(); + const draft = drafts.find((item) => item.id === id); + + if (!draft) { + return NextResponse.json({ error: "Draft not found." }, { status: 404 }); + } + + return NextResponse.json({ draft }, { headers: { "Cache-Control": "no-store" } }); +} + +export async function PATCH(request: NextRequest, context: RouteParams) { + try { + const { id } = await context.params; + const body = (await request.json()) as PatchPayload; + + const drafts = await readBuilderDrafts(); + const index = drafts.findIndex((item) => item.id === id); + + if (index === -1) { + return NextResponse.json({ error: "Draft not found." }, { status: 404 }); + } + + const current = drafts[index]; + const nextName = typeof body.name === "string" ? body.name.trim() : current.name; + + const updated: BuilderDraft = { + ...current, + name: nextName, + slug: nextName !== current.name ? slugify(nextName) || current.slug : current.slug, + description: typeof body.description === "string" ? body.description.trim() : current.description, + version: typeof body.version === "string" && body.version.trim() ? body.version.trim() : current.version, + author: typeof body.author === "string" && body.author.trim() ? body.author.trim() : current.author, + tags: normalizeTags(body.tags) ?? current.tags, + files: normalizeFiles(body.files) ?? current.files, + status: body.status === "published" || body.status === "draft" ? body.status : current.status, + updatedAt: new Date().toISOString(), + }; + + drafts[index] = updated; + await writeBuilderDrafts(drafts); + + return NextResponse.json({ success: true, draft: updated }); + } catch { + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} + +export async function DELETE(_request: NextRequest, context: RouteParams) { + const { id } = await context.params; + const drafts = await readBuilderDrafts(); + const index = drafts.findIndex((item) => item.id === id); + + if (index === -1) { + return NextResponse.json({ error: "Draft not found." }, { status: 404 }); + } + + const [deleted] = drafts.splice(index, 1); + await writeBuilderDrafts(drafts); + + return NextResponse.json({ success: true, deleted }); +} diff --git a/src/app/api/builder/route.ts b/src/app/api/builder/route.ts new file mode 100644 index 00000000..dca692c7 --- /dev/null +++ b/src/app/api/builder/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BuilderDraft, readBuilderDrafts, slugify, writeBuilderDrafts } from "@/lib/builderStore"; + +function makeId(name: string): string { + const base = slugify(name) || "skill"; + return `${base}_${Date.now().toString(36)}`; +} + +function parsePayload(body: unknown): Omit | null { + if (!body || typeof body !== "object") return null; + + const payload = body as { + name?: unknown; + description?: unknown; + version?: unknown; + author?: unknown; + tags?: unknown; + files?: unknown; + status?: unknown; + }; + + const name = typeof payload.name === "string" ? payload.name.trim() : ""; + if (!name) return null; + + const tags = Array.isArray(payload.tags) + ? payload.tags + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean) + : []; + + const files = Array.isArray(payload.files) + ? payload.files + .map((file) => { + if (!file || typeof file !== "object") return null; + + const fileName = typeof (file as { name?: unknown }).name === "string" + ? (file as { name: string }).name.trim() + : ""; + + if (!fileName) return null; + + return { + name: fileName, + content: typeof (file as { content?: unknown }).content === "string" + ? (file as { content: string }).content + : "", + }; + }) + .filter((file): file is { name: string; content: string } => file !== null) + : []; + + return { + name, + description: typeof payload.description === "string" ? payload.description.trim() : "", + version: typeof payload.version === "string" && payload.version.trim() ? payload.version.trim() : "0.1.0", + author: typeof payload.author === "string" && payload.author.trim() ? payload.author.trim() : "Unknown", + tags, + files, + status: payload.status === "published" ? "published" : "draft", + }; +} + +export async function GET() { + const drafts = await readBuilderDrafts(); + const sorted = [...drafts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + + return NextResponse.json( + { + drafts: sorted, + count: sorted.length, + }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as unknown; + const payload = parsePayload(body); + + if (!payload) { + return NextResponse.json({ error: "Name is required." }, { status: 400 }); + } + + const now = new Date().toISOString(); + const draft: BuilderDraft = { + ...payload, + id: makeId(payload.name), + slug: slugify(payload.name) || makeId(payload.name), + createdAt: now, + updatedAt: now, + }; + + const drafts = await readBuilderDrafts(); + drafts.unshift(draft); + await writeBuilderDrafts(drafts); + + return NextResponse.json({ success: true, draft }, { status: 201 }); + } catch { + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} diff --git a/src/app/builder/page.tsx b/src/app/builder/page.tsx index 7357a42d..9b017c61 100644 --- a/src/app/builder/page.tsx +++ b/src/app/builder/page.tsx @@ -1,795 +1,524 @@ +/* eslint-disable react/no-unescaped-entities */ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; -type Step = 1 | 2 | 3 | 4; +type DraftStatus = "draft" | "published"; -interface ExamplePair { - input: string; - output: string; -} +type DraftFile = { + name: string; + content: string; +}; -interface SkillData { - // Step 1: Info +type SkillDraft = { + id: string; name: string; + slug: string; description: string; - category: string; + version: string; + author: string; tags: string[]; + files: DraftFile[]; + createdAt: string; + updatedAt: string; + status: DraftStatus; +}; - // Step 2: Config - model: string; - temperature: number; - toolsNeeded: string[]; - - // Step 3: Logic - systemPrompt: string; - examples: ExamplePair[]; -} +type DraftInput = { + name: string; + description: string; + version: string; + author: string; + tags: string[]; + files: DraftFile[]; + status: DraftStatus; +}; -const CATEGORIES = [ - "Automation", - "Communication", - "Data Analysis", - "Development Tools", - "File Management", - "Media Processing", - "System Integration", - "Web Scraping", - "Security", - "Creative", - "Other", -]; - -const AVAILABLE_TOOLS = [ - "read", - "write", - "edit", - "exec", - "process", - "web_search", - "web_fetch", - "browser", - "canvas", - "nodes", - "message", - "image", - "tts", -]; - -const MODELS = [ - "anthropic/claude-opus-4", - "anthropic/claude-sonnet-4", - "anthropic/claude-sonnet-3.5", - "anthropic/claude-haiku-3.5", - "openai/gpt-4o", - "openai/gpt-4-turbo", - "openai/o1", - "openai/o1-mini", -]; - -const SAMPLE_SKILL: SkillData = { - name: "Code Reviewer", - description: "Analyzes code for bugs, security issues, and best practices", - category: "Development Tools", - tags: ["code-review", "security", "best-practices"], - model: "anthropic/claude-sonnet-4", - temperature: 0.3, - toolsNeeded: ["read", "write", "exec"], - systemPrompt: `You are an expert code reviewer with deep knowledge of security, performance, and best practices. - -When reviewing code: -1. Identify security vulnerabilities -2. Check for performance issues -3. Suggest improvements following industry best practices -4. Point out potential bugs -5. Recommend better design patterns when applicable - -Always be constructive and explain the reasoning behind your suggestions.`, - examples: [ - { - input: "Review this function for security issues:\n\nfunction getUserData(userId) {\n return db.query('SELECT * FROM users WHERE id = ' + userId);\n}", - output: "🚨 **Critical Security Issue: SQL Injection Vulnerability**\n\nThis code is vulnerable to SQL injection attacks. An attacker could manipulate the userId parameter to execute arbitrary SQL.\n\n**Fixed version:**\n```javascript\nfunction getUserData(userId) {\n return db.query('SELECT * FROM users WHERE id = ?', [userId]);\n}\n```\n\nUse parameterized queries to safely handle user input.", - }, - { - input: "Is this code efficient?\n\nfor (let i = 0; i < array.length; i++) {\n console.log(array[i]);\n}", - output: "⚠️ **Performance Issue: Recalculating array.length**\n\nWhile not critical, this recalculates array.length on each iteration.\n\n**Better approach:**\n```javascript\nconst len = array.length;\nfor (let i = 0; i < len; i++) {\n console.log(array[i]);\n}\n```\n\nOr use a more modern approach:\n```javascript\narray.forEach(item => console.log(item));\n```", - }, - ], +const EMPTY_DRAFT: DraftInput = { + name: "", + description: "", + version: "0.1.0", + author: "", + tags: [], + files: [{ name: "SKILL.md", content: "# New Skill\n" }], + status: "draft", }; export default function BuilderPage() { - const [currentStep, setCurrentStep] = useState(1); - const [customTagInput, setCustomTagInput] = useState(""); - const [skillData, setSkillData] = useState(() => { - // Load from localStorage or use sample - if (typeof window !== "undefined") { - const saved = localStorage.getItem("skillBuilderDraft"); - if (saved) { - try { - return JSON.parse(saved); - } catch (e) { - console.error("Failed to load draft:", e); + const [drafts, setDrafts] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [editor, setEditor] = useState(EMPTY_DRAFT); + const [selectedFileIndex, setSelectedFileIndex] = useState(0); + const [tagInput, setTagInput] = useState(""); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const selectedDraft = useMemo( + () => drafts.find((draft) => draft.id === selectedId) ?? null, + [drafts, selectedId] + ); + + useEffect(() => { + void loadDrafts(); + }, []); + + const loadDrafts = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch("/api/builder", { cache: "no-store" }); + if (!response.ok) throw new Error("Failed to load drafts"); + + const data = (await response.json()) as { drafts?: SkillDraft[] }; + const nextDrafts = Array.isArray(data.drafts) ? data.drafts : []; + setDrafts(nextDrafts); + + if (nextDrafts.length > 0) { + const current = selectedId + ? nextDrafts.find((draft) => draft.id === selectedId) + : nextDrafts[0]; + + if (current) { + setSelectedId(current.id); + setEditor(toInput(current)); + setSelectedFileIndex(0); } } + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Unable to load drafts"); + } finally { + setLoading(false); } - return SAMPLE_SKILL; + }; + + const toInput = (draft: SkillDraft): DraftInput => ({ + name: draft.name, + description: draft.description, + version: draft.version, + author: draft.author, + tags: draft.tags, + files: draft.files.length > 0 ? draft.files : [{ name: "SKILL.md", content: "" }], + status: draft.status, }); - // Auto-save to localStorage - useEffect(() => { - localStorage.setItem("skillBuilderDraft", JSON.stringify(skillData)); - }, [skillData]); - - const updateField = ( - key: K, - value: SkillData[K] - ) => { - setSkillData((prev) => ({ ...prev, [key]: value })); + const selectDraft = (draft: SkillDraft) => { + setSelectedId(draft.id); + setEditor(toInput(draft)); + setSelectedFileIndex(0); + setMessage(null); + setError(null); }; - const addCustomTag = (tag: string) => { - const trimmed = tag.trim().toLowerCase(); - if (trimmed && !skillData.tags.includes(trimmed)) { - setSkillData((prev) => ({ - ...prev, - tags: [...prev.tags, trimmed], - })); + const newDraft = () => { + setSelectedId(null); + setEditor(EMPTY_DRAFT); + setSelectedFileIndex(0); + setMessage("Creating a new draft. Save when ready."); + setError(null); + }; + + const saveDraft = async () => { + if (!editor.name.trim()) { + setError("Name is required."); + return; + } + + setSaving(true); + setError(null); + setMessage(null); + + const payload = { + ...editor, + tags: editor.tags, + files: editor.files, + }; + + try { + const response = await fetch(selectedId ? `/api/builder/${selectedId}` : "/api/builder", { + method: selectedId ? "PATCH" : "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(selectedId ? "Failed to save draft" : "Failed to create draft"); + } + + const data = (await response.json()) as { draft?: SkillDraft }; + if (data.draft) { + const updatedDraft = data.draft; + setDrafts((prev) => { + const exists = prev.some((draft) => draft.id === updatedDraft.id); + if (!exists) return [updatedDraft, ...prev]; + return prev.map((draft) => (draft.id === updatedDraft.id ? updatedDraft : draft)); + }); + setSelectedId(updatedDraft.id); + setEditor(toInput(updatedDraft)); + } + + setMessage(selectedId ? "Draft saved." : "Draft created."); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "Save failed."); + } finally { + setSaving(false); } }; - const removeTag = (tag: string) => { - setSkillData((prev) => ({ - ...prev, - tags: prev.tags.filter((t) => t !== tag), - })); + const publishDraft = async () => { + if (!selectedId) { + setError("Save this draft before publishing."); + return; + } + + setSaving(true); + setError(null); + setMessage(null); + + try { + const response = await fetch(`/api/builder/${selectedId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...editor, status: "published" }), + }); + + if (!response.ok) throw new Error("Failed to publish draft"); + + const data = (await response.json()) as { draft?: SkillDraft }; + if (data.draft) { + setDrafts((prev) => prev.map((draft) => (draft.id === data.draft?.id ? data.draft : draft))); + setEditor(toInput(data.draft)); + } + + setMessage("Draft published."); + } catch (publishError) { + setError(publishError instanceof Error ? publishError.message : "Publish failed."); + } finally { + setSaving(false); + } }; - const toggleTool = (tool: string) => { - setSkillData((prev) => ({ - ...prev, - toolsNeeded: prev.toolsNeeded.includes(tool) - ? prev.toolsNeeded.filter((t) => t !== tool) - : [...prev.toolsNeeded, tool], - })); + const deleteDraft = async (id: string) => { + const confirmed = window.confirm("Delete this draft?"); + if (!confirmed) return; + + setSaving(true); + setError(null); + setMessage(null); + + try { + const response = await fetch(`/api/builder/${id}`, { method: "DELETE" }); + if (!response.ok) throw new Error("Failed to delete draft"); + + const nextDrafts = drafts.filter((draft) => draft.id !== id); + setDrafts(nextDrafts); + + if (selectedId === id) { + const nextSelected = nextDrafts[0] ?? null; + setSelectedId(nextSelected?.id ?? null); + setEditor(nextSelected ? toInput(nextSelected) : EMPTY_DRAFT); + } + + setMessage("Draft deleted."); + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : "Delete failed."); + } finally { + setSaving(false); + } }; - const addExample = () => { - setSkillData((prev) => ({ - ...prev, - examples: [...prev.examples, { input: "", output: "" }], - })); + const addTag = () => { + const tag = tagInput.trim().toLowerCase(); + if (!tag || editor.tags.includes(tag)) return; + setEditor((prev) => ({ ...prev, tags: [...prev.tags, tag] })); + setTagInput(""); }; - const updateExample = (index: number, field: "input" | "output", value: string) => { - setSkillData((prev) => ({ + const removeTag = (tag: string) => { + setEditor((prev) => ({ ...prev, - examples: prev.examples.map((ex, i) => - i === index ? { ...ex, [field]: value } : ex - ), + tags: prev.tags.filter((item) => item !== tag), })); }; - const removeExample = (index: number) => { - setSkillData((prev) => ({ + const addFile = () => { + const file = { name: `file-${editor.files.length + 1}.md`, content: "" }; + setEditor((prev) => ({ ...prev, - examples: prev.examples.filter((_, i) => i !== index), + files: [...prev.files, file], })); + setSelectedFileIndex(editor.files.length); }; - const canProceed = (step: Step): boolean => { - switch (step) { - case 1: - return !!( - skillData.name.trim() && - skillData.description.trim() && - skillData.category && - skillData.tags.length > 0 - ); - case 2: - return !!(skillData.model && skillData.toolsNeeded.length > 0); - case 3: - return !!skillData.systemPrompt.trim(); - default: - return false; + const removeFile = (index: number) => { + if (editor.files.length <= 1) { + setError("At least one file is required."); + return; } - }; - const generateSkillJson = () => { - return JSON.stringify( - { - name: skillData.name, - description: skillData.description, - category: skillData.category, - tags: skillData.tags, - config: { - model: skillData.model, - temperature: skillData.temperature, - tools: skillData.toolsNeeded, - }, - systemPrompt: skillData.systemPrompt, - examples: skillData.examples.filter( - (ex) => ex.input.trim() || ex.output.trim() - ), - }, - null, - 2 - ); - }; - - const handleDownload = () => { - const json = generateSkillJson(); - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${skillData.name.toLowerCase().replace(/\s+/g, "-")}-skill.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const handlePublish = () => { - // In a real implementation, this would POST to an API - alert( - "🚀 In production, this would publish your skill to forAgents.dev!\n\nFor now, the skill has been downloaded locally." - ); - handleDownload(); + setEditor((prev) => ({ + ...prev, + files: prev.files.filter((_, fileIndex) => fileIndex !== index), + })); + setSelectedFileIndex((prev) => Math.max(0, prev - (prev >= index ? 1 : 0))); }; - const handleReset = () => { - if ( - confirm( - "Are you sure you want to reset? This will clear all your current work." - ) - ) { - setSkillData(SAMPLE_SKILL); - setCurrentStep(1); - localStorage.removeItem("skillBuilderDraft"); - } + const updateFile = (index: number, key: keyof DraftFile, value: string) => { + setEditor((prev) => ({ + ...prev, + files: prev.files.map((file, fileIndex) => + fileIndex === index ? { ...file, [key]: value } : file + ), + })); }; - const renderProgressBar = () => ( -
- {[1, 2, 3, 4].map((step) => ( -
-
step - ? "bg-cyan/30 text-cyan" - : "bg-card border border-white/10 text-muted-foreground" - }`} - > - {step} + return ( +
+
+
+
+

Skill Builder

+

Build, save, and publish reusable skills.

- {step < 4 && ( -
step ? "bg-cyan" : "bg-white/10" - }`} - /> - )} -
- ))} -
- ); - - const renderStepTitle = () => { - const titles = { - 1: "Skill Information", - 2: "Configuration", - 3: "Logic & Examples", - 4: "Publish Your Skill", - }; - return ( -

- {titles[currentStep]} -

- ); - }; - - const renderStep1 = () => { - return ( -
-
- - updateField("name", e.target.value)} - placeholder="e.g., Code Reviewer, Image Generator, Data Analyzer" - className="w-full px-4 py-3 rounded-lg bg-card border border-white/10 text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-cyan/50 transition-colors" - /> -
- -
- -