Compare commits

...

2 Commits

5 changed files with 432 additions and 118 deletions

108
app/api/ai-assist/route.ts Normal file
View 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
View 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 });
}
}

View File

@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
import useStore from "@/lib/store"; import useStore from "@/lib/store";
import { translations } from "@/lib/i18n/translations"; import { translations } from "@/lib/i18n/translations";
import modelAdapter from "@/lib/services/adapter-instance"; import modelAdapter from "@/lib/services/adapter-instance";
import { safeJsonFetch } from "@/lib/safeJsonFetch";
// --- Types --- // --- Types ---
@@ -267,6 +268,10 @@ export default function AIAssist() {
const [viewMode, setViewMode] = useState<"preview" | "code">("preview"); const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
const [abortController, setAbortController] = useState<AbortController | null>(null); 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 scrollRef = useRef<HTMLDivElement>(null);
const isPreviewRenderable = (preview?: PreviewData | null) => { const isPreviewRenderable = (preview?: PreviewData | null) => {
if (!preview) return false; if (!preview) return false;
@@ -309,23 +314,28 @@ export default function AIAssist() {
loadModels(); loadModels();
}, [selectedProvider, selectedModels, setSelectedModel]); }, [selectedProvider, selectedModels, setSelectedModel]);
const handleSendMessage = async (e?: React.FormEvent) => { const handleSendMessage = async (e?: React.FormEvent, forcedPrompt?: string) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
if (!input.trim() || isProcessing) return; const finalInput = forcedPrompt || input;
if (!finalInput.trim() || isProcessing) return;
const controller = new AbortController(); const controller = new AbortController();
setAbortController(controller); setAbortController(controller);
// UI Update for user message
if (!forcedPrompt) {
const userMsg: AIAssistMessage = { const userMsg: AIAssistMessage = {
role: "user", role: "user",
content: input, content: finalInput,
timestamp: new Date(), timestamp: new Date(),
}; };
const newHistory = [...aiAssistHistory, userMsg]; const newHistory = [...aiAssistHistory, userMsg];
setAIAssistHistory(newHistory); setAIAssistHistory(newHistory);
setInput(""); setInput("");
}
setIsProcessing(true); setIsProcessing(true);
if (assistStep === "idle") setAssistStep("plan");
const assistantMsg: AIAssistMessage = { const assistantMsg: AIAssistMessage = {
role: "assistant", role: "assistant",
@@ -333,21 +343,61 @@ export default function AIAssist() {
agent: currentAgent, agent: currentAgent,
timestamp: new Date() timestamp: new Date()
}; };
setAIAssistHistory([...newHistory, assistantMsg]); setAIAssistHistory(prev => [...prev, assistantMsg]);
try { 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 accumulated = "";
let lastParsedPreview: PreviewData | null = null; let lastParsedPreview: PreviewData | null = null;
const response = await modelAdapter.generateAIAssistStream( const response = await modelAdapter.generateAIAssistStream(
{ {
messages: newHistory, messages: [...aiAssistHistory, { role: "system", content: prompt } as any],
currentAgent, currentAgent,
onChunk: (chunk) => { onChunk: (chunk) => {
accumulated += chunk; accumulated += chunk;
const { chatDisplay, preview, agent } = parseStreamingContent(accumulated); 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)) { if (preview && JSON.stringify(preview) !== JSON.stringify(lastParsedPreview)) {
setPreviewData(preview); setPreviewData(preview);
lastParsedPreview = preview; lastParsedPreview = preview;
@@ -374,40 +424,17 @@ export default function AIAssist() {
selectedProvider, selectedProvider,
selectedModels[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) { } catch (error) {
console.error("Assist error:", 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 => { setAIAssistHistory(prev => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
const message = error instanceof Error ? error.message : "AI Assist failed"; 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 = () => { const stopGeneration = () => {
if (abortController) { if (abortController) {
abortController.abort(); abortController.abort();
@@ -434,6 +466,8 @@ export default function AIAssist() {
setAIAssistHistory([]); setAIAssistHistory([]);
setPreviewData(null); setPreviewData(null);
setShowCanvas(false); setShowCanvas(false);
setAssistStep("idle");
setAiPlan(null);
}; };
return ( return (
@@ -572,6 +606,42 @@ export default function AIAssist() {
</ReactMarkdown> </ReactMarkdown>
</div> </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 && ( {msg.role === "assistant" && msg.preview && (
<Button <Button
variant="secondary" variant="secondary"
@@ -644,7 +714,8 @@ export default function AIAssist() {
</div> </div>
{/* --- Canvas Panel --- */} {/* --- 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)"> <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)]"> <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"> <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> </div>
</Card> </Card>
</div> </div>
)} )
}
<style jsx global>{` <style jsx global>{`
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700;800&display=swap');
.ai-assist { .ai-assist {

View File

@@ -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 = () => { const handleDownloadHtml = () => {
if (!slidesPresentation) return; if (!slidesPresentation) return;
@@ -1205,9 +1226,12 @@ export default function SlidesGenerator() {
<Button variant="ghost" size="icon" onClick={toggleFullscreen} className="h-8 w-8"> <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" />} {isFullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button> </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" /> <Download className="h-3.5 w-3.5" />
</Button> </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"> <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" />} {copied ? <CheckCircle2 className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button> </Button>

73
lib/safeJsonFetch.ts Normal file
View 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),
},
};
}
}