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 { 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);
const userMsg: AIAssistMessage = {
role: "user",
content: input,
timestamp: new Date(),
};
// UI Update for user message
if (!forcedPrompt) {
const userMsg: AIAssistMessage = {
role: "user",
content: finalInput,
timestamp: new Date(),
};
const newHistory = [...aiAssistHistory, userMsg];
setAIAssistHistory(newHistory);
setInput("");
}
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,82 +714,84 @@ export default function AIAssist() {
</div>
{/* --- Canvas Panel --- */}
{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">
<div className="flex items-center gap-4">
<div className="p-2.5 bg-blue-500/10 rounded-2xl border border-blue-500/20">
{viewMode === "preview" ? <Monitor className="h-5 w-5 text-blue-400" /> : <Code2 className="h-5 w-5 text-amber-300" />}
</div>
<div>
<h3 className="text-xs font-black text-blue-50 uppercase tracking-[0.2em]">{previewData?.type || "Live"} Canvas</h3>
<div className="flex bg-blue-900/60 rounded-xl p-1 mt-2">
<button
onClick={() => setViewMode("preview")}
className={cn("px-4 py-1.5 text-[10px] uppercase font-black rounded-lg transition-all", viewMode === "preview" ? "bg-blue-500 text-white shadow-lg" : "text-blue-300/60 hover:text-blue-100")}
>
Live Render
</button>
<button
onClick={() => setViewMode("code")}
className={cn("px-4 py-1.5 text-[10px] uppercase font-black rounded-lg transition-all", viewMode === "code" ? "bg-blue-500 text-white shadow-lg" : "text-blue-300/60 hover:text-blue-100")}
>
Inspect Code
</button>
{
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">
<div className="flex items-center gap-4">
<div className="p-2.5 bg-blue-500/10 rounded-2xl border border-blue-500/20">
{viewMode === "preview" ? <Monitor className="h-5 w-5 text-blue-400" /> : <Code2 className="h-5 w-5 text-amber-300" />}
</div>
<div>
<h3 className="text-xs font-black text-blue-50 uppercase tracking-[0.2em]">{previewData?.type || "Live"} Canvas</h3>
<div className="flex bg-blue-900/60 rounded-xl p-1 mt-2">
<button
onClick={() => setViewMode("preview")}
className={cn("px-4 py-1.5 text-[10px] uppercase font-black rounded-lg transition-all", viewMode === "preview" ? "bg-blue-500 text-white shadow-lg" : "text-blue-300/60 hover:text-blue-100")}
>
Live Render
</button>
<button
onClick={() => setViewMode("code")}
className={cn("px-4 py-1.5 text-[10px] uppercase font-black rounded-lg transition-all", viewMode === "code" ? "bg-blue-500 text-white shadow-lg" : "text-blue-300/60 hover:text-blue-100")}
>
Inspect Code
</button>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-blue-200/70 hover:text-white hover:bg-blue-900 rounded-2xl"
onClick={() => navigator.clipboard.writeText(previewData?.data || "")}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-blue-200/70 hover:text-rose-400 hover:bg-rose-500/10 rounded-2xl"
onClick={() => setShowCanvas(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden relative">
{viewMode === "preview" && previewData ? (
<LiveCanvas
data={previewData.data}
type={previewData.type}
isStreaming={!!previewData.isStreaming}
/>
) : (
<div className="h-full bg-[#050505] p-8 font-mono text-sm overflow-auto scrollbar-thin scrollbar-thumb-blue-900">
<pre className="text-blue-300/90 leading-relaxed selection:bg-blue-500/20 whitespace-pre-wrap">
<code>{previewData?.data}</code>
</pre>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-blue-200/70 hover:text-white hover:bg-blue-900 rounded-2xl"
onClick={() => navigator.clipboard.writeText(previewData?.data || "")}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-blue-200/70 hover:text-rose-400 hover:bg-rose-500/10 rounded-2xl"
onClick={() => setShowCanvas(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
)}
</div>
<div className="px-6 py-3 border-t border-blue-900/40 bg-[#0b1414]/70 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", previewData?.isStreaming ? "bg-amber-500 animate-pulse" : "bg-blue-500")} />
<span className="text-[10px] text-blue-200/60 font-bold uppercase tracking-widest leading-none">
{previewData?.isStreaming ? "Neural Link Active" : "Sync Complete"}
</span>
</div>
<Badge variant="outline" className="text-[9px] border-blue-900 text-blue-200/50 font-black">
{previewData?.language?.toUpperCase()} UTF-8
</Badge>
</div>
</Card>
</div>
)}
<div className="flex-1 overflow-hidden relative">
{viewMode === "preview" && previewData ? (
<LiveCanvas
data={previewData.data}
type={previewData.type}
isStreaming={!!previewData.isStreaming}
/>
) : (
<div className="h-full bg-[#050505] p-8 font-mono text-sm overflow-auto scrollbar-thin scrollbar-thumb-blue-900">
<pre className="text-blue-300/90 leading-relaxed selection:bg-blue-500/20 whitespace-pre-wrap">
<code>{previewData?.data}</code>
</pre>
</div>
)}
</div>
<div className="px-6 py-3 border-t border-blue-900/40 bg-[#0b1414]/70 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", previewData?.isStreaming ? "bg-amber-500 animate-pulse" : "bg-blue-500")} />
<span className="text-[10px] text-blue-200/60 font-bold uppercase tracking-widest leading-none">
{previewData?.isStreaming ? "Neural Link Active" : "Sync Complete"}
</span>
</div>
<Badge variant="outline" className="text-[9px] border-blue-900 text-blue-200/50 font-black">
{previewData?.language?.toUpperCase()} UTF-8
</Badge>
</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 >
);
}

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 = () => {
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
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),
},
};
}
}