feat(book): add interactive elements and API endpoint for book demos
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# Interactive Book Improvements Plan
|
||||
|
||||
Add more interactive elements to the prompting book so users can learn by doing, not just reading — with real OpenAI integration and rate limiting.
|
||||
|
||||
## ✅ Implementation Complete
|
||||
|
||||
All high-priority components have been implemented and added to book chapters.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### API Endpoint ✅
|
||||
- **`src/app/api/book/demo/route.ts`** — Rate-limited API for book demos
|
||||
- Auth users: 5 requests/minute
|
||||
- Anonymous: 3 requests/minute + 10/day total
|
||||
- Supports: `run_prompt`, `analyze_prompt`, `score_challenge`, `compare_prompts`, `validate_blanks`
|
||||
|
||||
### New Components ✅
|
||||
|
||||
| Component | File | AI? | Status |
|
||||
|-----------|------|-----|--------|
|
||||
| **PromptBuilder** | `builder.tsx` | ✅ | ✅ Implemented |
|
||||
| **PromptAnalyzer** | `builder.tsx` | ✅ | ✅ Implemented |
|
||||
| **FillInTheBlank** | `exercises.tsx` | ✅ (optional) | ✅ Implemented |
|
||||
| **InteractiveChecklist** | `exercises.tsx` | ❌ | ✅ Implemented |
|
||||
| **PromptDebugger** | `exercises.tsx` | ❌ | ✅ Implemented |
|
||||
| **PromptChallenge** | `challenge.tsx` | ✅ | ✅ Implemented |
|
||||
| **BeforeAfterEditor** | `challenge.tsx` | ✅ | ✅ Implemented |
|
||||
|
||||
### Chapter Updates ✅
|
||||
|
||||
| Chapter | Components Added |
|
||||
|---------|------------------|
|
||||
| 02 - Anatomy of Effective Prompt | `PromptBuilder`, `PromptChallenge` |
|
||||
| 03 - Core Prompting Principles | `FillInTheBlank`, `InteractiveChecklist` |
|
||||
| 08 - Iterative Refinement | `BeforeAfterEditor` |
|
||||
| 15 - Common Pitfalls | `PromptAnalyzer`, `PromptDebugger` |
|
||||
|
||||
### Files Modified ✅
|
||||
- `src/components/book/elements/index.ts` — Added exports
|
||||
- `src/components/book/interactive.tsx` — Added exports
|
||||
- `mdx-components.tsx` — Registered components for MDX
|
||||
|
||||
## Not Implemented (Low Priority)
|
||||
|
||||
| Component | Reason |
|
||||
|-----------|--------|
|
||||
| PromptDiffViewer | Low impact, existing `DiffView` component covers similar use case |
|
||||
|
||||
## Testing
|
||||
|
||||
Run dev server and visit:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- `/book/02-anatomy-of-effective-prompt` — PromptBuilder + Challenge
|
||||
- `/book/03-core-prompting-principles` — FillInTheBlank + Checklist
|
||||
- `/book/08-iterative-refinement` — BeforeAfterEditor
|
||||
- `/book/15-common-pitfalls` — PromptAnalyzer + Debugger
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { BookPartsNav, BREAKFramework, Callout, ChainErrorDemo, ChainExample, ChainFlowDemo, Checklist, CodeEditor, Collapsible, Compare, ContentPipelineDemo, ContextPlayground, ContextWindowDemo, CostCalculatorDemo, CRISPEFramework, DiffView, EmbeddingsDemo, FallbackDemo, FewShotDemo, IconCheck, IconClipboard, IconLightbulb, IconLock, IconSettings, IconStar, IconTarget, IconUser, IconX, InfoGrid, IterativeRefinementDemo, JailbreakDemo, JsonYamlDemo, LLMCapabilitiesDemo, NavButton, NavFooter, PrinciplesSummary, PromptBreakdown, Quiz, RTFFramework, SpecificitySpectrum, StructuredOutputDemo, SummarizationDemo, TemperatureDemo, TextToImageDemo, TextToVideoDemo, TokenizerDemo, TokenPredictionDemo, TryIt, ValidationDemo, VersionDiff } from "@/components/book/interactive";
|
||||
import { BeforeAfterEditor, BookPartsNav, BREAKFramework, Callout, ChainErrorDemo, ChainExample, ChainFlowDemo, Checklist, CodeEditor, Collapsible, Compare, ContentPipelineDemo, ContextPlayground, ContextWindowDemo, CostCalculatorDemo, CRISPEFramework, DiffView, EmbeddingsDemo, FallbackDemo, FewShotDemo, FillInTheBlank, IconCheck, IconClipboard, IconLightbulb, IconLock, IconSettings, IconStar, IconTarget, IconUser, IconX, InfoGrid, InteractiveChecklist, IterativeRefinementDemo, JailbreakDemo, JsonYamlDemo, LLMCapabilitiesDemo, NavButton, NavFooter, PrinciplesSummary, PromptAnalyzer, PromptBreakdown, PromptBuilder, PromptChallenge, PromptDebugger, Quiz, RTFFramework, SpecificitySpectrum, StructuredOutputDemo, SummarizationDemo, TemperatureDemo, TextToImageDemo, TextToVideoDemo, TokenizerDemo, TokenPredictionDemo, TryIt, ValidationDemo, VersionDiff } from "@/components/book/interactive";
|
||||
|
||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
return {
|
||||
@@ -25,6 +25,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
tr: ({ ref: _ref, ...props }: ComponentPropsWithoutRef<"tr"> & { ref?: unknown }) => (
|
||||
<tr className="even:bg-muted/30" {...props} />
|
||||
),
|
||||
BeforeAfterEditor,
|
||||
BookPartsNav,
|
||||
BREAKFramework,
|
||||
Callout,
|
||||
@@ -44,6 +45,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
EmbeddingsDemo,
|
||||
FallbackDemo,
|
||||
FewShotDemo,
|
||||
FillInTheBlank,
|
||||
IconCheck,
|
||||
IconClipboard,
|
||||
IconLightbulb,
|
||||
@@ -54,6 +56,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
IconUser,
|
||||
IconX,
|
||||
InfoGrid,
|
||||
InteractiveChecklist,
|
||||
IterativeRefinementDemo,
|
||||
JailbreakDemo,
|
||||
JsonYamlDemo,
|
||||
@@ -61,7 +64,11 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
NavButton,
|
||||
NavFooter,
|
||||
PrinciplesSummary,
|
||||
PromptAnalyzer,
|
||||
PromptBreakdown,
|
||||
PromptBuilder,
|
||||
PromptChallenge,
|
||||
PromptDebugger,
|
||||
Quiz,
|
||||
RTFFramework,
|
||||
SpecificitySpectrum,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import OpenAI from "openai";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
const GENERATIVE_MODEL = process.env.OPENAI_GENERATIVE_MODEL || "gpt-4o-mini";
|
||||
|
||||
// Rate limiting configuration
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const RATE_LIMIT_MAX_REQUESTS_AUTH = 5; // 5 requests per minute for authenticated users
|
||||
const RATE_LIMIT_MAX_REQUESTS_ANON = 3; // 3 requests per minute for anonymous users
|
||||
const DAILY_LIMIT_ANON = 10; // 10 requests per day for anonymous users
|
||||
|
||||
// In-memory rate limit stores
|
||||
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
|
||||
const dailyLimitStore = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
function getClientId(request: NextRequest, userId?: string): string {
|
||||
if (userId) return `user:${userId}`;
|
||||
|
||||
// For anonymous users, use IP address
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
const ip = forwarded ? forwarded.split(",")[0].trim() : "unknown";
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
|
||||
function checkRateLimit(
|
||||
clientId: string,
|
||||
isAuthenticated: boolean
|
||||
): { allowed: boolean; remaining: number; resetIn: number; dailyRemaining?: number } {
|
||||
const now = Date.now();
|
||||
const maxRequests = isAuthenticated ? RATE_LIMIT_MAX_REQUESTS_AUTH : RATE_LIMIT_MAX_REQUESTS_ANON;
|
||||
|
||||
// Check per-minute rate limit
|
||||
const userLimit = rateLimitStore.get(clientId);
|
||||
|
||||
if (!userLimit || now > userLimit.resetAt) {
|
||||
rateLimitStore.set(clientId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||
} else if (userLimit.count >= maxRequests) {
|
||||
return { allowed: false, remaining: 0, resetIn: userLimit.resetAt - now };
|
||||
} else {
|
||||
userLimit.count++;
|
||||
}
|
||||
|
||||
const remaining = maxRequests - (rateLimitStore.get(clientId)?.count || 1);
|
||||
const resetIn = (rateLimitStore.get(clientId)?.resetAt || now + RATE_LIMIT_WINDOW_MS) - now;
|
||||
|
||||
// For anonymous users, also check daily limit
|
||||
if (!isAuthenticated) {
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const dailyLimit = dailyLimitStore.get(clientId);
|
||||
|
||||
if (!dailyLimit || now > dailyLimit.resetAt) {
|
||||
dailyLimitStore.set(clientId, { count: 1, resetAt: now + dayMs });
|
||||
} else if (dailyLimit.count >= DAILY_LIMIT_ANON) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetIn: dailyLimit.resetAt - now,
|
||||
dailyRemaining: 0
|
||||
};
|
||||
} else {
|
||||
dailyLimit.count++;
|
||||
}
|
||||
|
||||
const dailyRemaining = DAILY_LIMIT_ANON - (dailyLimitStore.get(clientId)?.count || 1);
|
||||
return { allowed: true, remaining, resetIn, dailyRemaining };
|
||||
}
|
||||
|
||||
return { allowed: true, remaining, resetIn };
|
||||
}
|
||||
|
||||
export type DemoType =
|
||||
| "run_prompt" // Run a prompt and get response
|
||||
| "analyze_prompt" // Analyze prompt quality
|
||||
| "score_challenge" // Score a challenge submission
|
||||
| "compare_prompts" // Compare two prompts
|
||||
| "validate_blanks"; // Validate fill-in-the-blank answers semantically
|
||||
|
||||
interface BlankValidation {
|
||||
id: string;
|
||||
expectedAnswers: string[];
|
||||
userAnswer: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
interface RequestBody {
|
||||
type: DemoType;
|
||||
prompt?: string;
|
||||
prompts?: [string, string]; // For compare
|
||||
task?: string; // For challenge scoring
|
||||
criteria?: string[]; // For challenge scoring
|
||||
systemPrompt?: string;
|
||||
blanks?: BlankValidation[]; // For validate_blanks
|
||||
template?: string; // For validate_blanks
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPTS: Record<DemoType, string> = {
|
||||
run_prompt: "You are a helpful AI assistant. Respond naturally and helpfully to the user's prompt. Keep responses concise but complete.",
|
||||
|
||||
analyze_prompt: `You are a prompt engineering expert. Analyze the given prompt and provide structured feedback.
|
||||
|
||||
Return JSON with this exact structure:
|
||||
{
|
||||
"score": <number 1-10>,
|
||||
"clarity": "<brief assessment>",
|
||||
"specificity": "<brief assessment>",
|
||||
"missingElements": ["<element1>", "<element2>"],
|
||||
"suggestions": ["<suggestion1>", "<suggestion2>"],
|
||||
"improved": "<improved version of the prompt>"
|
||||
}`,
|
||||
|
||||
score_challenge: `You are a prompt engineering instructor evaluating a student's prompt.
|
||||
|
||||
Given a task and criteria, score how well the submitted prompt addresses them.
|
||||
|
||||
Return JSON with this exact structure:
|
||||
{
|
||||
"score": <number 0-100>,
|
||||
"criteriaScores": [{"criterion": "<name>", "met": <boolean>, "feedback": "<brief>"}],
|
||||
"overallFeedback": "<2-3 sentences>",
|
||||
"suggestions": ["<improvement1>", "<improvement2>"]
|
||||
}`,
|
||||
|
||||
compare_prompts: `You are a prompt engineering expert comparing two prompts.
|
||||
|
||||
Analyze both prompts and explain which is better and why.
|
||||
|
||||
Return JSON with this exact structure:
|
||||
{
|
||||
"winner": <1 or 2>,
|
||||
"prompt1Analysis": {"strengths": ["..."], "weaknesses": ["..."]},
|
||||
"prompt2Analysis": {"strengths": ["..."], "weaknesses": ["..."]},
|
||||
"explanation": "<why the winner is better>",
|
||||
"keyDifferences": ["<difference1>", "<difference2>"]
|
||||
}`,
|
||||
|
||||
validate_blanks: `You are validating fill-in-the-blank answers for a prompt engineering exercise.
|
||||
|
||||
For each blank, determine if the user's answer is semantically equivalent to any of the expected answers. Be lenient with synonyms and variations that convey the same meaning (e.g., "software engineer" = "software developer" = "developer").
|
||||
|
||||
Return JSON with this exact structure:
|
||||
{
|
||||
"validations": [
|
||||
{
|
||||
"blankId": "<id>",
|
||||
"isCorrect": <boolean>,
|
||||
"feedback": "<brief feedback if incorrect, explaining why or suggesting improvement>"
|
||||
}
|
||||
]
|
||||
}`
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
const isAuthenticated = !!session?.user;
|
||||
const userId = session?.user?.id || session?.user?.email;
|
||||
|
||||
const clientId = getClientId(request, userId);
|
||||
const rateLimit = checkRateLimit(clientId, isAuthenticated);
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: "Rate limit exceeded. Please try again later.",
|
||||
resetIn: Math.ceil(rateLimit.resetIn / 1000),
|
||||
dailyRemaining: rateLimit.dailyRemaining,
|
||||
signInForMore: !isAuthenticated
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-RateLimit-Remaining": String(rateLimit.remaining),
|
||||
"X-RateLimit-Reset": String(Math.ceil(rateLimit.resetIn / 1000)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (!config.features.aiGeneration) {
|
||||
return new Response(JSON.stringify({ error: "AI features are not enabled" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return new Response(JSON.stringify({ error: "OpenAI API key not configured" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const body: RequestBody = await request.json();
|
||||
const { type, prompt, prompts, task, criteria, systemPrompt } = body;
|
||||
|
||||
if (!type || !SYSTEM_PROMPTS[type]) {
|
||||
return new Response(JSON.stringify({ error: "Invalid demo type" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: process.env.OPENAI_BASE_URL || undefined,
|
||||
});
|
||||
|
||||
let userContent: string;
|
||||
let responseFormat: "text" | "json_object" = "text";
|
||||
|
||||
switch (type) {
|
||||
case "run_prompt":
|
||||
if (!prompt) {
|
||||
return new Response(JSON.stringify({ error: "Prompt is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
userContent = prompt;
|
||||
break;
|
||||
|
||||
case "analyze_prompt":
|
||||
if (!prompt) {
|
||||
return new Response(JSON.stringify({ error: "Prompt is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
userContent = `Analyze this prompt:\n\n${prompt}`;
|
||||
responseFormat = "json_object";
|
||||
break;
|
||||
|
||||
case "score_challenge":
|
||||
if (!prompt || !task) {
|
||||
return new Response(JSON.stringify({ error: "Prompt and task are required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
userContent = `Task: ${task}\n\nCriteria to evaluate:\n${(criteria || []).map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nStudent's submitted prompt:\n${prompt}`;
|
||||
responseFormat = "json_object";
|
||||
break;
|
||||
|
||||
case "compare_prompts":
|
||||
if (!prompts || prompts.length !== 2) {
|
||||
return new Response(JSON.stringify({ error: "Two prompts are required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
userContent = `Compare these two prompts:\n\nPrompt 1:\n${prompts[0]}\n\nPrompt 2:\n${prompts[1]}`;
|
||||
responseFormat = "json_object";
|
||||
break;
|
||||
|
||||
case "validate_blanks":
|
||||
if (!body.blanks || body.blanks.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "Blanks are required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
userContent = `Validate these fill-in-the-blank answers:\n\n${body.blanks.map(b =>
|
||||
`Blank ID: ${b.id}\nExpected answers (examples): ${b.expectedAnswers.join(", ")}\nUser's answer: "${b.userAnswer}"${b.context ? `\nContext: ${b.context}` : ""}`
|
||||
).join("\n\n")}`;
|
||||
responseFormat = "json_object";
|
||||
break;
|
||||
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: "Invalid demo type" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: GENERATIVE_MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt || SYSTEM_PROMPTS[type] },
|
||||
{ role: "user", content: userContent }
|
||||
],
|
||||
temperature: type === "run_prompt" ? 0.7 : 0.3,
|
||||
max_tokens: 500,
|
||||
...(responseFormat === "json_object" && { response_format: { type: "json_object" } }),
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content || "";
|
||||
|
||||
let result: unknown;
|
||||
if (responseFormat === "json_object") {
|
||||
try {
|
||||
result = JSON.parse(content);
|
||||
} catch {
|
||||
result = { error: "Failed to parse response", raw: content };
|
||||
}
|
||||
} else {
|
||||
result = content;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
result,
|
||||
remaining: rateLimit.remaining,
|
||||
dailyRemaining: rateLimit.dailyRemaining,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-RateLimit-Remaining": String(rateLimit.remaining),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Book demo API error:", error);
|
||||
return new Response(JSON.stringify({ error: "Failed to process request" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Play, Copy, Check, Loader2, RefreshCw, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ============================================================================
|
||||
// PromptBuilder Component - Step-by-step prompt construction
|
||||
// ============================================================================
|
||||
|
||||
interface PromptBuilderProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
defaultValues?: {
|
||||
role?: string;
|
||||
context?: string;
|
||||
task?: string;
|
||||
constraints?: string;
|
||||
format?: string;
|
||||
examples?: string;
|
||||
};
|
||||
showAllFields?: boolean;
|
||||
}
|
||||
|
||||
interface BuilderField {
|
||||
id: keyof NonNullable<PromptBuilderProps["defaultValues"]>;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
hint: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const BUILDER_FIELDS: BuilderField[] = [
|
||||
{
|
||||
id: "role",
|
||||
label: "Role / Persona",
|
||||
placeholder: "You are a senior software engineer...",
|
||||
hint: "Who should the AI act as? What expertise should it have?",
|
||||
},
|
||||
{
|
||||
id: "context",
|
||||
label: "Context / Background",
|
||||
placeholder: "I'm building a React app that...",
|
||||
hint: "What does the AI need to know about your situation?",
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
label: "Task / Instruction",
|
||||
placeholder: "Review this code and identify bugs...",
|
||||
hint: "What specific action should the AI take?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "constraints",
|
||||
label: "Constraints / Rules",
|
||||
placeholder: "Keep response under 200 words. Focus only on...",
|
||||
hint: "What limitations or rules should the AI follow?",
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
label: "Output Format",
|
||||
placeholder: "Return as a numbered list with...",
|
||||
hint: "How should the response be structured?",
|
||||
},
|
||||
{
|
||||
id: "examples",
|
||||
label: "Examples",
|
||||
placeholder: "Example input: X → Output: Y",
|
||||
hint: "Show examples of what you want (few-shot learning)",
|
||||
},
|
||||
];
|
||||
|
||||
export function PromptBuilder({
|
||||
title = "Prompt Builder",
|
||||
description = "Build your prompt step by step",
|
||||
defaultValues = {},
|
||||
showAllFields = false,
|
||||
}: PromptBuilderProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>(defaultValues as Record<string, string>);
|
||||
const [expandedFields, setExpandedFields] = useState<Set<string>>(
|
||||
new Set(showAllFields ? BUILDER_FIELDS.map(f => f.id) : ["task"])
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [response, setResponse] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rateLimit, setRateLimit] = useState<{ remaining?: number; dailyRemaining?: number } | null>(null);
|
||||
|
||||
const toggleField = (id: string) => {
|
||||
setExpandedFields(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const buildPrompt = useCallback(() => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (values.role) {
|
||||
parts.push(`# Role\n${values.role}`);
|
||||
}
|
||||
if (values.context) {
|
||||
parts.push(`# Context\n${values.context}`);
|
||||
}
|
||||
if (values.task) {
|
||||
parts.push(`# Task\n${values.task}`);
|
||||
}
|
||||
if (values.constraints) {
|
||||
parts.push(`# Constraints\n${values.constraints}`);
|
||||
}
|
||||
if (values.format) {
|
||||
parts.push(`# Output Format\n${values.format}`);
|
||||
}
|
||||
if (values.examples) {
|
||||
parts.push(`# Examples\n${values.examples}`);
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}, [values]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(buildPrompt());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleRun = async () => {
|
||||
const prompt = buildPrompt();
|
||||
if (!prompt.trim()) {
|
||||
setError("Please add at least a task to your prompt");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setResponse(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/book/demo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "run_prompt",
|
||||
prompt,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
setError(`Rate limit reached. Try again in ${data.resetIn}s${data.signInForMore ? " or sign in for more." : "."}`);
|
||||
} else {
|
||||
setError(data.error || "Failed to run prompt");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setResponse(data.result);
|
||||
setRateLimit({ remaining: data.remaining, dailyRemaining: data.dailyRemaining });
|
||||
} catch {
|
||||
setError("Failed to connect to API");
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const prompt = buildPrompt();
|
||||
const hasContent = prompt.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="my-6 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-muted/50 border-b">
|
||||
<span className="font-semibold">{title}</span>
|
||||
<span className="text-muted-foreground text-sm ml-2">{description}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Builder Fields */}
|
||||
{BUILDER_FIELDS.map((field) => (
|
||||
<div key={field.id} className="border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleField(field.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between p-3 text-left transition-colors",
|
||||
expandedFields.has(field.id) ? "bg-muted/50" : "hover:bg-muted/30",
|
||||
values[field.id] && "border-l-4 border-l-primary"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{field.label}</span>
|
||||
{field.required && <span className="text-red-500 text-xs">*</span>}
|
||||
{values[field.id] && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
filled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expandedFields.has(field.id) ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedFields.has(field.id) && (
|
||||
<div className="p-3 pt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{field.hint}</p>
|
||||
<textarea
|
||||
value={values[field.id] || ""}
|
||||
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Preview */}
|
||||
{hasContent && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Generated Prompt</span>
|
||||
<span className="text-xs text-muted-foreground">{prompt.length} chars</span>
|
||||
</div>
|
||||
<pre className="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{prompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button onClick={handleCopy} variant="outline" size="sm" disabled={!hasContent}>
|
||||
{copied ? <Check className="h-4 w-4 mr-1" /> : <Copy className="h-4 w-4 mr-1" />}
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
<Button onClick={handleRun} size="sm" disabled={!hasContent || isRunning}>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Run with AI
|
||||
</Button>
|
||||
{rateLimit?.remaining !== undefined && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{rateLimit.remaining} {rateLimit.dailyRemaining !== undefined ? `(${rateLimit.dailyRemaining}/day)` : ""} remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response */}
|
||||
{response && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-400">AI Response</span>
|
||||
<Button onClick={() => setResponse(null)} variant="ghost" size="sm" className="h-6 text-xs">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 rounded-lg text-sm whitespace-pre-wrap">
|
||||
{response}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PromptAnalyzer Component - AI analyzes prompt quality
|
||||
// ============================================================================
|
||||
|
||||
interface AnalysisResult {
|
||||
score: number;
|
||||
clarity: string;
|
||||
specificity: string;
|
||||
missingElements: string[];
|
||||
suggestions: string[];
|
||||
improved: string;
|
||||
}
|
||||
|
||||
interface PromptAnalyzerProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
defaultPrompt?: string;
|
||||
}
|
||||
|
||||
export function PromptAnalyzer({
|
||||
title = "Prompt Analyzer",
|
||||
description = "Get AI feedback on your prompt",
|
||||
defaultPrompt = "",
|
||||
}: PromptAnalyzerProps) {
|
||||
const [prompt, setPrompt] = useState(defaultPrompt);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rateLimit, setRateLimit] = useState<{ remaining?: number } | null>(null);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!prompt.trim()) {
|
||||
setError("Please enter a prompt to analyze");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
setAnalysis(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/book/demo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "analyze_prompt",
|
||||
prompt,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
setError(`Rate limit reached. Try again in ${data.resetIn}s.`);
|
||||
} else {
|
||||
setError(data.error || "Failed to analyze prompt");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAnalysis(data.result as AnalysisResult);
|
||||
setRateLimit({ remaining: data.remaining });
|
||||
} catch {
|
||||
setError("Failed to connect to API");
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scoreColor = analysis?.score
|
||||
? analysis.score >= 8 ? "text-green-600"
|
||||
: analysis.score >= 5 ? "text-amber-600"
|
||||
: "text-red-600"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="my-6 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-muted/50 border-b">
|
||||
<span className="font-semibold">{title}</span>
|
||||
<span className="text-muted-foreground text-sm ml-2">{description}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Your Prompt</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Paste or write your prompt here..."
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary/50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleAnalyze} size="sm" disabled={!prompt.trim() || isAnalyzing}>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Analyze
|
||||
</Button>
|
||||
{rateLimit?.remaining !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rateLimit.remaining} remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className="space-y-4">
|
||||
{/* Score */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className={cn("text-4xl font-bold", scoreColor)}>{analysis.score}</div>
|
||||
<div className="text-xs text-muted-foreground">/ 10</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium w-20">Clarity</span>
|
||||
<span className="text-xs text-muted-foreground flex-1">{analysis.clarity}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium w-20">Specificity</span>
|
||||
<span className="text-xs text-muted-foreground flex-1">{analysis.specificity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing Elements */}
|
||||
{analysis.missingElements.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2 m-0!">Missing Elements</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{analysis.missingElements.map((el, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs rounded">
|
||||
{el}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{analysis.suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2 m-0!">Suggestions</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
{analysis.suggestions.map((s, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Improved Version */}
|
||||
{analysis.improved && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2 m-0!">Improved Version</p>
|
||||
<pre className="p-3 bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 rounded-lg text-sm whitespace-pre-wrap font-mono">
|
||||
{analysis.improved}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Play, Loader2, Trophy, Clock, RefreshCw, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ============================================================================
|
||||
// PromptChallenge Component - Timed exercises with AI scoring
|
||||
// ============================================================================
|
||||
|
||||
interface ChallengeResult {
|
||||
score: number;
|
||||
criteriaScores: Array<{ criterion: string; met: boolean; feedback: string }>;
|
||||
overallFeedback: string;
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
interface PromptChallengeProps {
|
||||
title?: string;
|
||||
task: string;
|
||||
criteria: string[];
|
||||
timeLimit?: number; // seconds, 0 = no limit
|
||||
hints?: string[];
|
||||
exampleSolution?: string;
|
||||
difficulty?: "beginner" | "intermediate" | "advanced";
|
||||
}
|
||||
|
||||
export function PromptChallenge({
|
||||
title = "Prompt Challenge",
|
||||
task,
|
||||
criteria,
|
||||
timeLimit = 0,
|
||||
hints = [],
|
||||
exampleSolution,
|
||||
difficulty = "intermediate",
|
||||
}: PromptChallengeProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isStarted, setIsStarted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<ChallengeResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState(timeLimit);
|
||||
const [showHints, setShowHints] = useState(false);
|
||||
const [hintsUsed, setHintsUsed] = useState(0);
|
||||
const [showSolution, setShowSolution] = useState(false);
|
||||
const [rateLimit, setRateLimit] = useState<{ remaining?: number } | null>(null);
|
||||
|
||||
// Timer
|
||||
useEffect(() => {
|
||||
if (!isStarted || timeLimit === 0 || result) return;
|
||||
|
||||
if (timeRemaining <= 0) {
|
||||
handleSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining(prev => prev - 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isStarted, timeRemaining, timeLimit, result]);
|
||||
|
||||
const handleStart = () => {
|
||||
setIsStarted(true);
|
||||
setTimeRemaining(timeLimit);
|
||||
setPrompt("");
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setHintsUsed(0);
|
||||
setShowSolution(false);
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!prompt.trim()) {
|
||||
setError("Please write a prompt before submitting");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/book/demo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "score_challenge",
|
||||
prompt,
|
||||
task,
|
||||
criteria,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
setError(`Rate limit reached. Try again in ${data.resetIn}s.`);
|
||||
} else {
|
||||
setError(data.error || "Failed to score challenge");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust score based on hints used
|
||||
const baseResult = data.result as ChallengeResult;
|
||||
const hintPenalty = hintsUsed * 5; // -5 points per hint
|
||||
const adjustedScore = Math.max(0, baseResult.score - hintPenalty);
|
||||
|
||||
setResult({ ...baseResult, score: adjustedScore });
|
||||
setRateLimit({ remaining: data.remaining });
|
||||
} catch {
|
||||
setError("Failed to connect to API");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [prompt, task, criteria, hintsUsed]);
|
||||
|
||||
const handleShowHint = () => {
|
||||
if (hintsUsed < hints.length) {
|
||||
setHintsUsed(prev => prev + 1);
|
||||
setShowHints(true);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const difficultyColors = {
|
||||
beginner: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
intermediate: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||
advanced: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
};
|
||||
|
||||
const scoreColor = result?.score
|
||||
? result.score >= 80 ? "text-green-600"
|
||||
: result.score >= 50 ? "text-amber-600"
|
||||
: "text-red-600"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="my-6 border-2 border-primary/20 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-primary/5 border-b border-primary/20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">{title}</span>
|
||||
<span className={cn("px-2 py-0.5 text-xs rounded-full", difficultyColors[difficulty])}>
|
||||
{difficulty}
|
||||
</span>
|
||||
</div>
|
||||
{isStarted && timeLimit > 0 && !result && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 font-mono text-sm",
|
||||
timeRemaining <= 30 && "text-red-600 animate-pulse"
|
||||
)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Task */}
|
||||
<div>
|
||||
<p className="font-medium mb-2 m-0!">Your Task:</p>
|
||||
<div className="p-3 bg-muted/50 rounded-lg text-sm">
|
||||
{task}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria */}
|
||||
<div>
|
||||
<p className="font-medium mb-2 m-0!">Your prompt will be scored on:</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{criteria.map((c, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-primary font-mono">{i + 1}.</span>
|
||||
<span className={cn(
|
||||
result?.criteriaScores?.[i]?.met === true && "text-green-600",
|
||||
result?.criteriaScores?.[i]?.met === false && "text-red-600"
|
||||
)}>
|
||||
{c}
|
||||
{result?.criteriaScores?.[i] && (
|
||||
<span className="ml-2">
|
||||
{result.criteriaScores[i].met ? "✓" : "✗"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Not started state */}
|
||||
{!isStarted && (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-muted-foreground mb-4 m-0!">
|
||||
{timeLimit > 0
|
||||
? `You'll have ${formatTime(timeLimit)} to complete this challenge.`
|
||||
: "Take your time to craft the best prompt."}
|
||||
</p>
|
||||
<Button onClick={handleStart} size="lg">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Start Challenge
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active challenge */}
|
||||
{isStarted && !result && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Your Prompt:</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Write your prompt here..."
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary/50 font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hints */}
|
||||
{hints.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowHints(!showHints)}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{showHints ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
Hints ({hintsUsed}/{hints.length} used, -5 points each)
|
||||
</button>
|
||||
{showHints && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{hints.slice(0, hintsUsed).map((hint, i) => (
|
||||
<div key={i} className="p-2 bg-amber-50 dark:bg-amber-950/30 rounded text-sm">
|
||||
<strong>Hint {i + 1}:</strong> {hint}
|
||||
</div>
|
||||
))}
|
||||
{hintsUsed < hints.length && (
|
||||
<Button onClick={handleShowHint} variant="outline" size="sm">
|
||||
Reveal Next Hint (-5 points)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleSubmit} disabled={!prompt.trim() || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Trophy className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Submit for Scoring
|
||||
</Button>
|
||||
{rateLimit?.remaining !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rateLimit.remaining} AI calls remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* Score */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className={cn("text-5xl font-bold", scoreColor)}>{result.score}</div>
|
||||
<div className="text-xs text-muted-foreground">/ 100</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium m-0!">
|
||||
{result.score >= 80 ? "🎉 Excellent!" : result.score >= 50 ? "👍 Good effort!" : "Keep practicing!"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground m-0!">{result.overallFeedback}</p>
|
||||
{hintsUsed > 0 && (
|
||||
<p className="text-xs text-amber-600 m-0! mt-1">
|
||||
({hintsUsed} hint{hintsUsed > 1 ? "s" : ""} used, -{hintsUsed * 5} points)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria breakdown */}
|
||||
<div>
|
||||
<p className="font-medium mb-2 m-0!">Criteria Breakdown:</p>
|
||||
<div className="space-y-2">
|
||||
{result.criteriaScores.map((cs, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"p-2 rounded-lg text-sm",
|
||||
cs.met
|
||||
? "bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800"
|
||||
: "bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{cs.met ? "✓" : "✗"}</span>
|
||||
<span className="font-medium">{cs.criterion}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground m-0! mt-1 ml-5">{cs.feedback}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{result.suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium mb-2 m-0!">Suggestions for Improvement:</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
{result.suggestions.map((s, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example solution */}
|
||||
{exampleSolution && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowSolution(!showSolution)}
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-primary"
|
||||
>
|
||||
{showSolution ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{showSolution ? "Hide" : "Show"} Example Solution
|
||||
</button>
|
||||
{showSolution && (
|
||||
<pre className="mt-2 p-3 bg-primary/5 border border-primary/20 rounded-lg text-sm whitespace-pre-wrap font-mono">
|
||||
{exampleSolution}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retry */}
|
||||
<Button onClick={handleStart} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BeforeAfterEditor Component - Edit prompts and compare
|
||||
// ============================================================================
|
||||
|
||||
interface BeforeAfterEditorProps {
|
||||
title?: string;
|
||||
badPrompt: string;
|
||||
idealPrompt: string;
|
||||
task?: string;
|
||||
showComparison?: boolean; // Show side-by-side AI responses
|
||||
}
|
||||
|
||||
export function BeforeAfterEditor({
|
||||
title = "Improve This Prompt",
|
||||
badPrompt,
|
||||
idealPrompt,
|
||||
task = "Improve this prompt to get better results",
|
||||
}: BeforeAfterEditorProps) {
|
||||
const [userPrompt, setUserPrompt] = useState(badPrompt);
|
||||
const [showIdeal, setShowIdeal] = useState(false);
|
||||
const [isComparing, setIsComparing] = useState(false);
|
||||
const [comparison, setComparison] = useState<{
|
||||
winner: number;
|
||||
prompt1Analysis: { strengths: string[]; weaknesses: string[] };
|
||||
prompt2Analysis: { strengths: string[]; weaknesses: string[] };
|
||||
explanation: string;
|
||||
keyDifferences: string[];
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCompare = async () => {
|
||||
setIsComparing(true);
|
||||
setError(null);
|
||||
setComparison(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/book/demo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "compare_prompts",
|
||||
prompts: [badPrompt, userPrompt],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
setError(`Rate limit reached. Try again in ${data.resetIn}s.`);
|
||||
} else {
|
||||
setError(data.error || "Failed to compare prompts");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setComparison(data.result);
|
||||
} catch {
|
||||
setError("Failed to connect to API");
|
||||
} finally {
|
||||
setIsComparing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setUserPrompt(badPrompt);
|
||||
setComparison(null);
|
||||
setShowIdeal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-6 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-muted/50 border-b">
|
||||
<span className="font-semibold">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground m-0!">{task}</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 mt-3">
|
||||
{/* Original */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block text-red-600 dark:text-red-400">
|
||||
Original (Weak) Prompt
|
||||
</label>
|
||||
<pre className="mt-0! p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg text-sm whitespace-pre-wrap h-40 overflow-y-auto">
|
||||
{badPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* User's version */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block text-primary">
|
||||
Your Improved Version
|
||||
</label>
|
||||
<textarea
|
||||
value={userPrompt}
|
||||
onChange={(e) => setUserPrompt(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary/50 font-mono h-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleCompare} disabled={isComparing || userPrompt === badPrompt}>
|
||||
{isComparing ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Compare with AI
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowIdeal(!showIdeal)}
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
>
|
||||
{showIdeal ? "Hide" : "Show"} Ideal Solution
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ideal solution */}
|
||||
{showIdeal && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block text-green-600 dark:text-green-400">
|
||||
Ideal Solution
|
||||
</label>
|
||||
<pre className="p-3 bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 rounded-lg text-sm whitespace-pre-wrap">
|
||||
{idealPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comparison results */}
|
||||
{comparison && (
|
||||
<div className="space-y-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{comparison.winner === 2
|
||||
? "🎉 Your version is better!"
|
||||
: "The original might still be better. Keep improving!"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm m-0!">{comparison.explanation}</p>
|
||||
|
||||
{comparison.keyDifferences.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1 m-0!">Key Differences:</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{comparison.keyDifferences.map((d, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
{d}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Check, X, RefreshCw, Lightbulb, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ============================================================================
|
||||
// FillInTheBlank Component
|
||||
// ============================================================================
|
||||
|
||||
interface BlankConfig {
|
||||
id: string;
|
||||
correctAnswers: string[]; // Multiple acceptable answers (used as examples for AI)
|
||||
hint?: string;
|
||||
caseSensitive?: boolean;
|
||||
context?: string; // Additional context for AI validation
|
||||
}
|
||||
|
||||
interface FillInTheBlankProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
template: string; // Use {{id}} for blanks
|
||||
blanks: BlankConfig[];
|
||||
explanation?: string;
|
||||
useAI?: boolean; // Enable AI-backed semantic validation
|
||||
}
|
||||
|
||||
interface AIValidationResult {
|
||||
blankId: string;
|
||||
isCorrect: boolean;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export function FillInTheBlank({
|
||||
title = "Fill in the Blanks",
|
||||
description,
|
||||
template,
|
||||
blanks,
|
||||
explanation,
|
||||
useAI = false
|
||||
}: FillInTheBlankProps) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [aiResults, setAiResults] = useState<Record<string, AIValidationResult>>({});
|
||||
const [showHints, setShowHints] = useState<Record<string, boolean>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const checkAnswerLocal = useCallback((blankId: string, value: string): boolean => {
|
||||
const blank = blanks.find(b => b.id === blankId);
|
||||
if (!blank) return false;
|
||||
|
||||
const normalizedValue = blank.caseSensitive ? value.trim() : value.trim().toLowerCase();
|
||||
return blank.correctAnswers.some(answer => {
|
||||
const normalizedAnswer = blank.caseSensitive ? answer.trim() : answer.trim().toLowerCase();
|
||||
return normalizedValue === normalizedAnswer;
|
||||
});
|
||||
}, [blanks]);
|
||||
|
||||
const checkAnswer = useCallback((blankId: string, value: string): boolean => {
|
||||
// If AI validation was used, check AI results
|
||||
if (useAI && aiResults[blankId]) {
|
||||
return aiResults[blankId].isCorrect;
|
||||
}
|
||||
// Fallback to local validation
|
||||
return checkAnswerLocal(blankId, value);
|
||||
}, [useAI, aiResults, checkAnswerLocal]);
|
||||
|
||||
const validateWithAI = async () => {
|
||||
setIsValidating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/book/demo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "validate_blanks",
|
||||
blanks: blanks.map(b => ({
|
||||
id: b.id,
|
||||
expectedAnswers: b.correctAnswers,
|
||||
userAnswer: answers[b.id] || "",
|
||||
context: b.context || b.hint,
|
||||
})),
|
||||
template,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
setError(`Rate limit reached. Using local validation.`);
|
||||
// Fallback to local validation
|
||||
setSubmitted(true);
|
||||
return;
|
||||
}
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Store AI results
|
||||
const results: Record<string, AIValidationResult> = {};
|
||||
for (const result of data.result.validations as AIValidationResult[]) {
|
||||
results[result.blankId] = result;
|
||||
}
|
||||
setAiResults(results);
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError("AI validation failed. Using local validation.");
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (useAI) {
|
||||
await validateWithAI();
|
||||
} else {
|
||||
setSubmitted(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAnswers({});
|
||||
setSubmitted(false);
|
||||
setAiResults({});
|
||||
setShowHints({});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const allCorrect = submitted && blanks.every(blank => checkAnswer(blank.id, answers[blank.id] || ""));
|
||||
const score = submitted ? blanks.filter(blank => checkAnswer(blank.id, answers[blank.id] || "")).length : 0;
|
||||
|
||||
// Parse template and render with inputs
|
||||
const renderTemplate = () => {
|
||||
const parts = template.split(/(\{\{[^}]+\}\})/g);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
const match = part.match(/\{\{([^}]+)\}\}/);
|
||||
if (match) {
|
||||
const blankId = match[1];
|
||||
const blank = blanks.find(b => b.id === blankId);
|
||||
const isCorrect = submitted && checkAnswer(blankId, answers[blankId] || "");
|
||||
const isWrong = submitted && !isCorrect;
|
||||
|
||||
return (
|
||||
<span key={index} className="inline-flex items-center gap-1 mx-1">
|
||||
<input
|
||||
type="text"
|
||||
value={answers[blankId] || ""}
|
||||
onChange={(e) => setAnswers(prev => ({ ...prev, [blankId]: e.target.value }))}
|
||||
disabled={submitted}
|
||||
placeholder="..."
|
||||
className={cn(
|
||||
"px-2 py-1 border-b-2 bg-transparent text-center min-w-[80px] max-w-[200px] focus:outline-none transition-colors",
|
||||
!submitted && "border-primary/50 focus:border-primary",
|
||||
isCorrect && "border-green-500 bg-green-50 dark:bg-green-950/30",
|
||||
isWrong && "border-red-500 bg-red-50 dark:bg-red-950/30"
|
||||
)}
|
||||
style={{ width: `${Math.max(80, (answers[blankId]?.length || 3) * 10)}px` }}
|
||||
/>
|
||||
{submitted && isCorrect && <Check className="h-4 w-4 text-green-500" />}
|
||||
{submitted && isWrong && <X className="h-4 w-4 text-red-500" />}
|
||||
{!submitted && blank?.hint && (
|
||||
<button
|
||||
onClick={() => setShowHints(prev => ({ ...prev, [blankId]: !prev[blankId] }))}
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
title="Show hint"
|
||||
>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={index}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-6 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-muted/50 border-b">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{description && <span className="text-muted-foreground text-sm ml-2">{description}</span>}
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="text-sm leading-relaxed font-mono bg-muted/30 p-4 rounded-lg whitespace-pre-wrap">
|
||||
{renderTemplate()}
|
||||
</div>
|
||||
|
||||
{/* Hints */}
|
||||
{Object.entries(showHints).filter(([, show]) => show).map(([blankId]) => {
|
||||
const blank = blanks.find(b => b.id === blankId);
|
||||
return blank?.hint ? (
|
||||
<div key={blankId} className="text-sm text-muted-foreground bg-amber-50 dark:bg-amber-950/30 p-2 rounded flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4 text-amber-500" />
|
||||
<span><strong>Hint for blank:</strong> {blank.hint}</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
{/* Results */}
|
||||
{submitted && (
|
||||
<div className={cn(
|
||||
"p-3 rounded-lg text-sm",
|
||||
allCorrect
|
||||
? "bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800"
|
||||
: "bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800"
|
||||
)}>
|
||||
<p className="font-medium m-0!">
|
||||
{allCorrect ? "🎉 Perfect!" : `${score} of ${blanks.length} correct`}
|
||||
</p>
|
||||
{!allCorrect && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{blanks.filter(blank => !checkAnswer(blank.id, answers[blank.id] || "")).map(blank => (
|
||||
<p key={blank.id} className="m-0! text-muted-foreground">
|
||||
<span className="text-red-600 dark:text-red-400">✗</span> Correct answer: <code className="bg-muted px-1 rounded">{blank.correctAnswers[0]}</code>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{explanation && <p className="mt-2 m-0!">{explanation}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-sm text-amber-700 dark:text-amber-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Feedback */}
|
||||
{useAI && submitted && Object.values(aiResults).some(r => r.feedback) && (
|
||||
<div className="space-y-1">
|
||||
{Object.values(aiResults).filter(r => r.feedback).map(result => (
|
||||
<div key={result.blankId} className="text-xs text-muted-foreground">
|
||||
{result.feedback}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{!submitted ? (
|
||||
<Button onClick={handleSubmit} size="sm" disabled={isValidating}>
|
||||
{isValidating ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isValidating ? "Checking..." : "Check Answers"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleReset} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
{useAI && !submitted && (
|
||||
<span className="text-xs text-muted-foreground self-center">AI-powered semantic validation</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InteractiveChecklist Component
|
||||
// ============================================================================
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface InteractiveChecklistProps {
|
||||
title?: string;
|
||||
items: ChecklistItem[];
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function InteractiveChecklist({
|
||||
title = "Checklist",
|
||||
items,
|
||||
onComplete
|
||||
}: InteractiveChecklistProps) {
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleItem = (id: string) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
if (next.size === items.length && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const progress = (checked.size / items.length) * 100;
|
||||
const allComplete = checked.size === items.length;
|
||||
|
||||
return (
|
||||
<div className="my-6 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-muted/50 border-b flex items-center justify-between">
|
||||
<span className="font-semibold">{title}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{checked.size}/{items.length} complete
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-300",
|
||||
allComplete ? "bg-green-500" : "bg-primary"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => toggleItem(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 p-3 rounded-lg text-left transition-colors",
|
||||
checked.has(item.id)
|
||||
? "bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800"
|
||||
: "bg-muted/30 hover:bg-muted/50 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-0.5 transition-colors",
|
||||
checked.has(item.id)
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: "border-muted-foreground/50"
|
||||
)}>
|
||||
{checked.has(item.id) && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn(
|
||||
"font-medium m-0! text-sm",
|
||||
checked.has(item.id) && "line-through text-muted-foreground"
|
||||
)}>
|
||||
{item.label}
|
||||
</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-muted-foreground m-0! mt-0.5">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{allComplete && (
|
||||
<div className="mt-4 p-3 bg-green-50 dark:bg-green-950/30 rounded-lg text-center">
|
||||
<p className="font-medium text-green-700 dark:text-green-300 m-0!">
|
||||
🎉 All done! Great work!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PromptDebugger Component
|
||||
// ============================================================================
|
||||
|
||||
interface DebugOption {
|
||||
id: string;
|
||||
label: string;
|
||||
isCorrect: boolean;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
interface PromptDebuggerProps {
|
||||
title?: string;
|
||||
badPrompt: string;
|
||||
badOutput: string;
|
||||
options: DebugOption[];
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function PromptDebugger({
|
||||
title = "Debug This Prompt",
|
||||
badPrompt,
|
||||
badOutput,
|
||||
options,
|
||||
hint
|
||||
}: PromptDebuggerProps) {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
|
||||
const selectedOption = options.find(o => o.id === selected);
|
||||
|
||||
return (
|
||||
<div className="my-6 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-muted/50 border-b flex items-center justify-between">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{hint && !selected && (
|
||||
<button
|
||||
onClick={() => setShowHint(!showHint)}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
{showHint ? "Hide hint" : "Show hint"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Bad prompt */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1 m-0!">The Prompt:</p>
|
||||
<pre className="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap">{badPrompt}</pre>
|
||||
</div>
|
||||
|
||||
{/* Bad output */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1 m-0!">The Output (problematic):</p>
|
||||
<pre className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg text-sm whitespace-pre-wrap">{badOutput}</pre>
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
{showHint && hint && (
|
||||
<div className="p-2 bg-amber-50 dark:bg-amber-950/30 rounded text-sm flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question */}
|
||||
<div>
|
||||
<p className="font-medium m-0! mb-2">What's wrong with this prompt?</p>
|
||||
<div className="space-y-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setSelected(option.id)}
|
||||
disabled={!!selected}
|
||||
className={cn(
|
||||
"w-full p-3 text-left rounded-lg border transition-colors text-sm",
|
||||
selected === option.id
|
||||
? option.isCorrect
|
||||
? "bg-green-100 border-green-500 dark:bg-green-950 dark:border-green-700"
|
||||
: "bg-red-100 border-red-500 dark:bg-red-950 dark:border-red-700"
|
||||
: selected && option.isCorrect
|
||||
? "bg-green-100 border-green-500 dark:bg-green-950 dark:border-green-700"
|
||||
: "hover:bg-muted border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
{option.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explanation */}
|
||||
{selected && selectedOption && (
|
||||
<div className={cn(
|
||||
"p-3 rounded-lg text-sm",
|
||||
selectedOption.isCorrect
|
||||
? "bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800"
|
||||
: "bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800"
|
||||
)}>
|
||||
<p className="font-medium m-0!">
|
||||
{selectedOption.isCorrect ? "✓ Correct!" : "✗ Not quite."}
|
||||
</p>
|
||||
<p className="m-0! mt-1">{selectedOption.explanation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset */}
|
||||
{selected && (
|
||||
<Button onClick={() => setSelected(null)} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,3 +17,6 @@ export { TokenPredictionDemo } from "./token-prediction";
|
||||
export { DiffView, VersionDiff } from "./diff-view";
|
||||
export { ChainErrorDemo } from "./chain-error-demo";
|
||||
export { ValidationDemo, FallbackDemo, ContentPipelineDemo } from "./chain-demos";
|
||||
export { FillInTheBlank, InteractiveChecklist, PromptDebugger } from "./exercises";
|
||||
export { PromptBuilder, PromptAnalyzer } from "./builder";
|
||||
export { PromptChallenge, BeforeAfterEditor } from "./challenge";
|
||||
|
||||
@@ -18,3 +18,6 @@ export { TokenPredictionDemo } from "./elements/token-prediction";
|
||||
export { DiffView, VersionDiff } from "./elements/diff-view";
|
||||
export { ChainErrorDemo } from "./elements/chain-error-demo";
|
||||
export { ValidationDemo, FallbackDemo, ContentPipelineDemo } from "./elements/chain-demos";
|
||||
export { FillInTheBlank, InteractiveChecklist, PromptDebugger } from "./elements/exercises";
|
||||
export { PromptBuilder, PromptAnalyzer } from "./elements/builder";
|
||||
export { PromptChallenge, BeforeAfterEditor } from "./elements/challenge";
|
||||
|
||||
@@ -296,4 +296,48 @@ Format: For each feature, provide:
|
||||
description="This prompt uses all six components. Try it and see how the structured approach produces focused, actionable results."
|
||||
/>
|
||||
|
||||
## Build Your Own Prompt
|
||||
|
||||
Now it's your turn! Use this interactive prompt builder to construct your own prompt using the components you've learned:
|
||||
|
||||
<PromptBuilder
|
||||
title="Interactive Prompt Builder"
|
||||
description="Fill in each section to build a complete, well-structured prompt"
|
||||
/>
|
||||
|
||||
<PromptChallenge
|
||||
title="Chapter Challenge: Build a Code Review Prompt"
|
||||
task="Write a prompt that asks an AI to review code for security vulnerabilities. Your prompt should be specific enough to get actionable feedback."
|
||||
criteria={[
|
||||
"Includes a clear role or expertise level",
|
||||
"Specifies what type of code review (security focus)",
|
||||
"Defines the expected output format",
|
||||
"Sets appropriate constraints or scope"
|
||||
]}
|
||||
hints={[
|
||||
"Think about what expertise a code reviewer should have",
|
||||
"Be specific about what security issues to look for",
|
||||
"Consider asking for a structured response format"
|
||||
]}
|
||||
exampleSolution={`You are a senior security engineer with expertise in web application security and OWASP Top 10 vulnerabilities.
|
||||
|
||||
Task: Review the following code for security vulnerabilities.
|
||||
|
||||
Focus on:
|
||||
- SQL injection risks
|
||||
- XSS vulnerabilities
|
||||
- Authentication/authorization issues
|
||||
- Input validation gaps
|
||||
|
||||
Output format:
|
||||
For each issue found:
|
||||
1. Line number(s)
|
||||
2. Vulnerability type
|
||||
3. Risk level (High/Medium/Low)
|
||||
4. Recommended fix
|
||||
|
||||
[CODE TO REVIEW]`}
|
||||
difficulty="intermediate"
|
||||
/>
|
||||
|
||||
In the next chapter, we'll explore the core principles that guide prompt construction decisions.
|
||||
|
||||
@@ -324,4 +324,49 @@ List any issues found.
|
||||
explanation="Context Is King emphasizes that AI models have no memory between sessions and cannot read your mind. Including relevant background, constraints, and goals helps the model understand your needs."
|
||||
/>
|
||||
|
||||
## Practice: Fill in the Blanks
|
||||
|
||||
Test your understanding of the core principles by completing this prompt template:
|
||||
|
||||
<FillInTheBlank
|
||||
title="Apply the Principles"
|
||||
description="Fill in the blanks to create a well-structured prompt following the 8 principles"
|
||||
useAI={true}
|
||||
template={`You are a {{role}} with expertise in {{expertise}}.
|
||||
|
||||
Context: I'm working on {{context}}.
|
||||
|
||||
Task: {{task}}
|
||||
|
||||
Constraints:
|
||||
- Keep your response under {{length}} words
|
||||
- Focus only on {{focus}}
|
||||
|
||||
Format: Return your answer as {{format}}.`}
|
||||
blanks={[
|
||||
{ id: "role", correctAnswers: ["senior developer", "software engineer", "technical writer", "data scientist"], hint: "What professional role should the AI assume?", context: "A job title or professional role" },
|
||||
{ id: "expertise", correctAnswers: ["Python", "React", "web development", "data analysis", "machine learning"], hint: "What specific domain knowledge is needed?", context: "A technical skill or domain" },
|
||||
{ id: "context", correctAnswers: ["a web application", "a mobile app", "a data pipeline", "an API"], hint: "What's the project or situation?", context: "A project type or work situation" },
|
||||
{ id: "task", correctAnswers: ["Review this code", "Write a function", "Debug this issue", "Explain this concept"], hint: "What specific action should the AI take?", context: "An action verb with an object" },
|
||||
{ id: "length", correctAnswers: ["200", "300", "500", "100"], hint: "How long should the response be?", context: "A number representing word count" },
|
||||
{ id: "focus", correctAnswers: ["performance", "security", "readability", "best practices"], hint: "What aspect should be prioritized?", context: "A quality or aspect to focus on" },
|
||||
{ id: "format", correctAnswers: ["a numbered list", "bullet points", "a table", "JSON", "markdown"], hint: "How should the output be structured?", context: "An output format type" }
|
||||
]}
|
||||
explanation="A well-structured prompt includes: a clear role (Principle 1), sufficient context (Principle 3), specific task (Principle 2), constraints (Principle 4), and output format (Principle 5)."
|
||||
/>
|
||||
|
||||
<InteractiveChecklist
|
||||
title="Principles Checklist"
|
||||
items={[
|
||||
{ id: "clarity", label: "Clarity Over Cleverness", description: "Is your prompt explicit and unambiguous?" },
|
||||
{ id: "specificity", label: "Specificity Yields Quality", description: "Have you included audience, length, tone, and format?" },
|
||||
{ id: "context", label: "Context Is King", description: "Does the prompt include all necessary background information?" },
|
||||
{ id: "examples", label: "Examples Beat Explanations", description: "Have you shown what you want, not just described it?" },
|
||||
{ id: "constraints", label: "Constraints Focus Output", description: "Are there clear boundaries on scope and format?" },
|
||||
{ id: "iteration", label: "Iterate and Refine", description: "Are you prepared to improve based on results?" },
|
||||
{ id: "persona", label: "Persona Shapes Perspective", description: "Does the AI know what role to play?" },
|
||||
{ id: "verify", label: "Verify and Validate", description: "Have you built in checks for accuracy?" }
|
||||
]}
|
||||
/>
|
||||
|
||||
These principles form the foundation for everything that follows. In Part II, we'll apply them to specific techniques that dramatically enhance prompt effectiveness.
|
||||
|
||||
@@ -357,4 +357,31 @@ Start simple, observe carefully, change one thing at a time, document what works
|
||||
explanation="Changing one thing at a time lets you isolate what works and what doesn't. If you change multiple things at once, you won't know which change fixed the problem or which made it worse."
|
||||
/>
|
||||
|
||||
## Practice: Improve This Prompt
|
||||
|
||||
Try improving this weak prompt yourself. Edit it, then use AI to compare your version with the original:
|
||||
|
||||
<BeforeAfterEditor
|
||||
title="Refine This Email Prompt"
|
||||
badPrompt="Write an email."
|
||||
idealPrompt={`You are a professional business writer.
|
||||
|
||||
Task: Write a follow-up email to a potential client after a sales meeting.
|
||||
|
||||
Context:
|
||||
- Met with Sarah Chen, VP of Marketing at TechCorp
|
||||
- Discussed our analytics platform
|
||||
- She expressed interest in the reporting features
|
||||
- Meeting was yesterday
|
||||
|
||||
Requirements:
|
||||
- Professional but warm tone
|
||||
- Reference specific points from our meeting
|
||||
- Include a clear next step (schedule a demo)
|
||||
- Keep under 150 words
|
||||
|
||||
Format: Subject line + email body`}
|
||||
task="Transform this vague email prompt into something that will produce a professional, effective result."
|
||||
/>
|
||||
|
||||
In the next chapter, we'll explore JSON and YAML prompting for structured data applications.
|
||||
|
||||
@@ -342,3 +342,32 @@ Before sending any important prompt, run through this quick checklist:
|
||||
correctIndex={1}
|
||||
explanation="While all pitfalls cause problems, trusting AI outputs without verification is the most dangerous because it can lead to publishing false information, deploying buggy code, or making decisions based on hallucinated data. AI sounds confident even when completely wrong, making verification essential for any important use case."
|
||||
/>
|
||||
|
||||
## Analyze Your Prompts
|
||||
|
||||
Use AI to get instant feedback on your prompt quality. Paste any prompt and get a detailed analysis:
|
||||
|
||||
<PromptAnalyzer
|
||||
title="Prompt Quality Analyzer"
|
||||
description="Get AI-powered feedback on clarity, specificity, and suggestions for improvement"
|
||||
defaultPrompt="Help me with my code"
|
||||
/>
|
||||
|
||||
## Debug This Prompt
|
||||
|
||||
Can you spot what's wrong with this prompt?
|
||||
|
||||
<PromptDebugger
|
||||
title="Find the Pitfall"
|
||||
badPrompt="Write a blog post about technology that's SEO optimized with keywords and also funny but professional and includes code examples and targets beginners but has advanced tips and mentions our product TechCo and has social proof and a call to action and is 500 words but comprehensive."
|
||||
badOutput="Here's a draft blog post about technology...
|
||||
|
||||
[Generic, unfocused content that tries to do everything but accomplishes nothing well. Tone shifts awkwardly between casual and technical. Missing half the requirements.]"
|
||||
options={[
|
||||
{ id: "vague", label: "The prompt is too vague", isCorrect: false, explanation: "Actually, the prompt has many specific requirements. The problem is the opposite—too many requirements, not too few." },
|
||||
{ id: "overload", label: "The prompt is overloaded with too many competing requirements", isCorrect: true, explanation: "Correct! This prompt asks for SEO + funny + professional + code + beginners + advanced + product mention + social proof + CTA + length constraint. That's 10+ competing requirements! The AI can't satisfy them all, so it does a mediocre job on everything. Solution: break this into multiple focused prompts." },
|
||||
{ id: "format", label: "The output format isn't specified", isCorrect: false, explanation: "While a more specific format would help, the main issue is requirement overload. You can't format your way out of asking for too much." },
|
||||
{ id: "context", label: "There's not enough context", isCorrect: false, explanation: "The prompt actually has a lot of context—maybe too much! The problem is that it's trying to satisfy too many goals at once." }
|
||||
]}
|
||||
hint="Count how many different requirements are packed into this single prompt."
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user