Compare commits
2 Commits
37612bbac2
...
42570a14b7
108
app/api/ai-assist/route.ts
Normal file
108
app/api/ai-assist/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
// Schema validation
|
||||
const schema = z.object({
|
||||
request: z.string().min(1),
|
||||
step: z.enum(["plan", "generate", "preview"]).default("plan"),
|
||||
plan: z.any().optional(),
|
||||
code: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional()
|
||||
});
|
||||
|
||||
const STEPS = {
|
||||
plan: `You are an expert software architect. Create a DETAILED DEVELOPMENT PLAN for the following request: "{request}"
|
||||
|
||||
Output ONLY a JSON object:
|
||||
{
|
||||
"summary": "One sentence overview",
|
||||
"architecture": "High-level components + data flow",
|
||||
"techStack": ["Next.js", "Tailwind", "Lucide Icons"],
|
||||
"files": [
|
||||
{"path": "app/page.tsx", "purpose": "Main UI"},
|
||||
{"path": "components/Preview.tsx", "purpose": "Core logic"}
|
||||
],
|
||||
"timeline": "Estimate",
|
||||
"risks": ["Potential blockers"]
|
||||
}`,
|
||||
|
||||
generate: `You are a Senior Vibe Coder. Execute the following approved plan:
|
||||
Plan: {plan}
|
||||
|
||||
Generate COMPLETE, PRODUCTION-READY code for all files.
|
||||
Focus on the request: "{request}"
|
||||
|
||||
Output ONLY a JSON object:
|
||||
{
|
||||
"files": {
|
||||
"app/page.tsx": "// code here",
|
||||
"components/UI.tsx": "// more code"
|
||||
},
|
||||
"explanation": "How it works"
|
||||
}`,
|
||||
|
||||
preview: `Convert the following code into a single-file interactive HTML preview (Standalone).
|
||||
Use Tailwind CDN.
|
||||
|
||||
Code: {code}
|
||||
|
||||
Output ONLY valid HTML.`
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = randomUUID();
|
||||
|
||||
try {
|
||||
// Safe body parsing
|
||||
const body = await req.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON body", requestId, success: false },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate schema
|
||||
const parseResult = schema.safeParse(body);
|
||||
if (!parseResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request body",
|
||||
details: parseResult.error.flatten(),
|
||||
requestId,
|
||||
success: false
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { request, step, plan, code } = parseResult.data;
|
||||
|
||||
let prompt = STEPS[step];
|
||||
prompt = prompt.replace("{request}", request);
|
||||
if (plan) prompt = prompt.replace("{plan}", JSON.stringify(plan));
|
||||
if (code) prompt = prompt.replace("{code}", code);
|
||||
|
||||
// Return the prompt for the frontend to use with the streaming adapter
|
||||
return NextResponse.json({
|
||||
prompt,
|
||||
step,
|
||||
requestId,
|
||||
success: true
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(`[ai-assist] requestId=${requestId}`, err);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err?.message ?? "AI Assist failed",
|
||||
requestId,
|
||||
success: false
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
37
app/api/slides/route.ts
Normal file
37
app/api/slides/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
topic: z.string().min(3),
|
||||
slideCount: z.number().min(3).max(15).default(8),
|
||||
style: z.enum(["professional", "creative", "technical", "pitch"]).default("professional"),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { topic, slideCount, style } = schema.parse(body);
|
||||
|
||||
const systemPrompt = `You are an elite presentation designer. Create a visually stunning presentation with ${slideCount} slides about "${topic}".
|
||||
|
||||
Style: ${style}
|
||||
|
||||
Output ONLY a sequence of slides separated by "---".
|
||||
Format each slide as:
|
||||
## [Slide Title]
|
||||
- [Bullet Point 1]
|
||||
- [Bullet Point 2]
|
||||
VISUAL: [Detailed description of image/chart/icon]
|
||||
---
|
||||
`;
|
||||
|
||||
// The frontend will handle the actual generation call to keep use of the ModelAdapter,
|
||||
// this route serves as the prompt orchestrator.
|
||||
return NextResponse.json({
|
||||
prompt: systemPrompt,
|
||||
success: true
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
|
||||
import useStore from "@/lib/store";
|
||||
import { translations } from "@/lib/i18n/translations";
|
||||
import modelAdapter from "@/lib/services/adapter-instance";
|
||||
import { safeJsonFetch } from "@/lib/safeJsonFetch";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -267,6 +268,10 @@ export default function AIAssist() {
|
||||
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||
|
||||
// Agentic States
|
||||
const [assistStep, setAssistStep] = useState<"idle" | "plan" | "generating" | "preview">("idle");
|
||||
const [aiPlan, setAiPlan] = useState<any>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isPreviewRenderable = (preview?: PreviewData | null) => {
|
||||
if (!preview) return false;
|
||||
@@ -309,23 +314,28 @@ export default function AIAssist() {
|
||||
loadModels();
|
||||
}, [selectedProvider, selectedModels, setSelectedModel]);
|
||||
|
||||
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||
const handleSendMessage = async (e?: React.FormEvent, forcedPrompt?: string) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!input.trim() || isProcessing) return;
|
||||
const finalInput = forcedPrompt || input;
|
||||
if (!finalInput.trim() || isProcessing) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
setAbortController(controller);
|
||||
|
||||
// UI Update for user message
|
||||
if (!forcedPrompt) {
|
||||
const userMsg: AIAssistMessage = {
|
||||
role: "user",
|
||||
content: input,
|
||||
content: finalInput,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const newHistory = [...aiAssistHistory, userMsg];
|
||||
setAIAssistHistory(newHistory);
|
||||
setInput("");
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
if (assistStep === "idle") setAssistStep("plan");
|
||||
|
||||
const assistantMsg: AIAssistMessage = {
|
||||
role: "assistant",
|
||||
@@ -333,21 +343,61 @@ export default function AIAssist() {
|
||||
agent: currentAgent,
|
||||
timestamp: new Date()
|
||||
};
|
||||
setAIAssistHistory([...newHistory, assistantMsg]);
|
||||
setAIAssistHistory(prev => [...prev, assistantMsg]);
|
||||
|
||||
try {
|
||||
// First, get the plan orchestrator prompt from our new API
|
||||
type AiAssistApiResponse = {
|
||||
prompt?: string;
|
||||
step?: string;
|
||||
requestId?: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const apiResult = await safeJsonFetch<AiAssistApiResponse>("/api/ai-assist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
request: finalInput,
|
||||
step: assistStep === "plan" ? "generate" : "plan",
|
||||
plan: aiPlan
|
||||
}),
|
||||
});
|
||||
|
||||
if (!apiResult.ok) {
|
||||
console.error("AI Assist API failed:", apiResult.error);
|
||||
throw new Error(apiResult.error.message);
|
||||
}
|
||||
|
||||
if (apiResult.data.error) {
|
||||
throw new Error(apiResult.data.error);
|
||||
}
|
||||
|
||||
const prompt = apiResult.data.prompt ?? "";
|
||||
|
||||
let accumulated = "";
|
||||
let lastParsedPreview: PreviewData | null = null;
|
||||
|
||||
const response = await modelAdapter.generateAIAssistStream(
|
||||
{
|
||||
messages: newHistory,
|
||||
messages: [...aiAssistHistory, { role: "system", content: prompt } as any],
|
||||
currentAgent,
|
||||
onChunk: (chunk) => {
|
||||
accumulated += chunk;
|
||||
const { chatDisplay, preview, agent } = parseStreamingContent(accumulated);
|
||||
|
||||
// Only update preview state if it actually changed to avoid iframe jitters
|
||||
// If we're in planning mode and see JSON, try to parse the plan
|
||||
if (assistStep === "plan" || assistStep === "idle") {
|
||||
const jsonMatch = accumulated.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (parsed.summary && parsed.files) setAiPlan(parsed);
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
if (preview && JSON.stringify(preview) !== JSON.stringify(lastParsedPreview)) {
|
||||
setPreviewData(preview);
|
||||
lastParsedPreview = preview;
|
||||
@@ -374,40 +424,17 @@ export default function AIAssist() {
|
||||
selectedProvider,
|
||||
selectedModels[selectedProvider]
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || "Streaming failed");
|
||||
|
||||
if (!response.success) throw new Error(response.error);
|
||||
|
||||
if (assistStep === "plan" || assistStep === "idle") {
|
||||
setAssistStep("plan");
|
||||
} else {
|
||||
setAssistStep("preview");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Assist error:", error);
|
||||
try {
|
||||
const fallback = await modelAdapter.generateAIAssist(
|
||||
{ messages: newHistory, currentAgent }
|
||||
);
|
||||
if (fallback.success && fallback.data) {
|
||||
const { chatDisplay, preview, agent } = parseStreamingContent(fallback.data);
|
||||
if (preview) {
|
||||
setPreviewData(preview);
|
||||
setShowCanvas(true);
|
||||
setViewMode(isPreviewRenderable(preview) ? "preview" : "code");
|
||||
}
|
||||
setAIAssistHistory(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
const nextAgent = agent || currentAgent;
|
||||
if (last && last.role === "assistant") {
|
||||
return [...prev.slice(0, -1), {
|
||||
...last,
|
||||
content: chatDisplay || fallback.data,
|
||||
agent: nextAgent,
|
||||
preview: preview ? { type: preview.type, data: preview.data, language: preview.language } : undefined
|
||||
} as AIAssistMessage];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error("Assist fallback error:", fallbackError);
|
||||
}
|
||||
setAIAssistHistory(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
const message = error instanceof Error ? error.message : "AI Assist failed";
|
||||
@@ -422,6 +449,11 @@ export default function AIAssist() {
|
||||
}
|
||||
};
|
||||
|
||||
const approveAndGenerate = () => {
|
||||
setAssistStep("generating");
|
||||
handleSendMessage(undefined, "Approved. Please generate the code according to the plan.");
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
@@ -434,6 +466,8 @@ export default function AIAssist() {
|
||||
setAIAssistHistory([]);
|
||||
setPreviewData(null);
|
||||
setShowCanvas(false);
|
||||
setAssistStep("idle");
|
||||
setAiPlan(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -572,6 +606,42 @@ export default function AIAssist() {
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* Agentic Plan Review Card */}
|
||||
{msg.role === "assistant" && aiPlan && i === aiAssistHistory.length - 1 && assistStep === "plan" && (
|
||||
<div className="mt-6 p-6 rounded-2xl bg-blue-500/5 border border-blue-500/20 backdrop-blur-sm animate-in zoom-in-95 duration-300">
|
||||
<h3 className="text-sm font-black text-blue-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<LayoutPanelLeft className="h-4 w-4" /> Proposed Solution Plan
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-slate-500 uppercase mb-1">Architecture</p>
|
||||
<p className="text-xs text-slate-400">{aiPlan.architecture}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-slate-500 uppercase mb-1">Tech Stack</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{aiPlan.techStack?.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[9px] border-blue-500/30 text-blue-300 px-1.5 py-0">{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-slate-500 uppercase mb-1">Files</p>
|
||||
<p className="text-[10px] text-slate-400">{aiPlan.files?.length} modules planned</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={approveAndGenerate}
|
||||
disabled={isProcessing}
|
||||
className="w-full mt-4 bg-blue-600 hover:bg-blue-500 text-white font-black uppercase text-[10px] tracking-widest py-5 rounded-xl shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
{isProcessing ? "Starting Engine..." : "Approve & Generate Development"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg.role === "assistant" && msg.preview && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -644,7 +714,8 @@ export default function AIAssist() {
|
||||
</div>
|
||||
|
||||
{/* --- Canvas Panel --- */}
|
||||
{showCanvas && (
|
||||
{
|
||||
showCanvas && (
|
||||
<div className="flex-1 h-full min-w-0 animate-in slide-in-from-right-12 duration-700 cubic-bezier(0,0,0.2,1)">
|
||||
<Card className="h-full flex flex-col bg-[#081010] rounded-[2.5rem] overflow-hidden border border-blue-900/60 shadow-[0_20px_80px_rgba(0,0,0,0.6)]">
|
||||
<div className="px-6 py-5 border-b border-blue-900/60 bg-[#0b1414]/70 backdrop-blur-2xl flex items-center justify-between shrink-0">
|
||||
@@ -719,7 +790,8 @@ export default function AIAssist() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}
|
||||
<style jsx global>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700;800&display=swap');
|
||||
.ai-assist {
|
||||
@@ -729,7 +801,7 @@ export default function AIAssist() {
|
||||
color: inherit;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -623,6 +623,27 @@ export default function SlidesGenerator() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadMarkdown = () => {
|
||||
if (!slidesPresentation) return;
|
||||
|
||||
// Reveal.js format
|
||||
const markdown = slidesPresentation.slides.map(s => {
|
||||
// Strip HTML but keep structure
|
||||
const cleanContent = s.content.replace(/<[^>]*>/g, '').trim();
|
||||
return `## ${s.title}\n\n${cleanContent}`;
|
||||
}).join('\n\n---\n\n');
|
||||
|
||||
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${slidesPresentation.title.replace(/\s+/g, '_')}_slides.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDownloadHtml = () => {
|
||||
if (!slidesPresentation) return;
|
||||
|
||||
@@ -1205,9 +1226,12 @@ export default function SlidesGenerator() {
|
||||
<Button variant="ghost" size="icon" onClick={toggleFullscreen} className="h-8 w-8">
|
||||
{isFullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleDownloadHtml} className="h-8 w-8">
|
||||
<Button variant="ghost" size="icon" onClick={handleDownloadHtml} title="Download HTML Presentation" className="h-8 w-8">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleDownloadMarkdown} title="Export Reveal.js Markdown" className="h-8 w-8 text-blue-500">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleCopy} className="h-8 w-8">
|
||||
{copied ? <CheckCircle2 className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
|
||||
73
lib/safeJsonFetch.ts
Normal file
73
lib/safeJsonFetch.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export class NonJsonResponseError extends Error {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
bodyPreview: string;
|
||||
|
||||
constructor(args: { status: number; contentType: string | null; bodyPreview: string }) {
|
||||
super(`Expected JSON but received ${args.contentType ?? "unknown content-type"} (HTTP ${args.status})`);
|
||||
this.name = "NonJsonResponseError";
|
||||
this.status = args.status;
|
||||
this.contentType = args.contentType;
|
||||
this.bodyPreview = args.bodyPreview;
|
||||
}
|
||||
}
|
||||
|
||||
type SafeJsonFetchResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: { message: string; status?: number; bodyPreview?: string } };
|
||||
|
||||
export async function safeJsonFetch<T>(
|
||||
url: string,
|
||||
init?: RequestInit
|
||||
): Promise<SafeJsonFetchResult<T>> {
|
||||
const res = await fetch(url, init);
|
||||
|
||||
const contentType = res.headers.get("content-type");
|
||||
const text = await res.text();
|
||||
|
||||
// HTTP error — return readable details (don't JSON.parse blindly)
|
||||
if (!res.ok) {
|
||||
// Try JSON first if it looks like JSON
|
||||
if (contentType?.includes("application/json")) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return { ok: false, error: { message: parsed?.error ?? "Request failed", status: res.status } };
|
||||
} catch {
|
||||
// fall through to generic
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: `Request failed (HTTP ${res.status})`,
|
||||
status: res.status,
|
||||
bodyPreview: text.slice(0, 300),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Success but not JSON => this is exactly the "Unexpected token <" case
|
||||
if (!contentType?.includes("application/json")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: `Server returned non-JSON (content-type: ${contentType ?? "unknown"})`,
|
||||
status: res.status,
|
||||
bodyPreview: text.slice(0, 300),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { ok: true, data: JSON.parse(text) as T };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: "Server returned invalid JSON",
|
||||
status: res.status,
|
||||
bodyPreview: text.slice(0, 300),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user