feat: complete overhaul of AI Assist with premium WOW level UI and stable preview engine
This commit is contained in:
@@ -38,15 +38,27 @@ export async function POST(request: NextRequest) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const payload = await response.text();
|
||||
if (!response.ok) {
|
||||
const payload = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: "Ollama chat request failed", details: payload },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(payload ? JSON.parse(payload) : {});
|
||||
// If stream is requested, pipe the response body
|
||||
if (body.stream) {
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-ndjson",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("Ollama chat proxy failed", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -44,23 +44,27 @@ export async function POST(request: NextRequest) {
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.text();
|
||||
if (!response.ok) {
|
||||
const payload = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: payload || response.statusText || "Qwen chat failed" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: payload || "Unexpected response format" },
|
||||
{ status: 502 }
|
||||
);
|
||||
// Handle streaming
|
||||
if (stream) {
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "internal_server_error", message: error instanceof Error ? error.message : "Qwen chat failed" },
|
||||
|
||||
@@ -1,509 +1,540 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef, useCallback, memo } from "react";
|
||||
import {
|
||||
MessageSquare, Send, Sparkles, Brain, Cpu, Code2, Palette, Search,
|
||||
Terminal, Eye, Trash2, Loader2, Bot, User, X, RotateCcw,
|
||||
CheckCircle2, Copy, Monitor, StopCircle, Maximize2, Minimize2,
|
||||
ChevronRight, Layout, Zap, Ghost
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AIAssistMessage } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select } from "@/components/ui/select";
|
||||
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 {
|
||||
MessageSquare, Send, Sparkles, Brain, Cpu, Code2, Palette, FileText, Search,
|
||||
BarChart, Rocket, Terminal, Eye, History, Trash2, Loader2, Bot, User,
|
||||
Settings, Layers, AppWindow, Smartphone, Monitor, X, ArrowLeftRight, RotateCcw,
|
||||
CheckCircle2
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AIAssistMessage } from "@/types";
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "general", label: "General Intel", icon: Bot, color: "slate" },
|
||||
{ id: "content", label: "Content Optimization", icon: FileText, color: "amber" },
|
||||
{ id: "seo", label: "SEO Analyst", icon: Search, color: "emerald" },
|
||||
{ id: "smm", label: "SMM Strategy", icon: BarChart, color: "pink" },
|
||||
{ id: "pm", label: "Project Manager", icon: Rocket, color: "indigo" },
|
||||
{ id: "code", label: "Code Architect", icon: Terminal, color: "violet" },
|
||||
{ id: "design", label: "UI/UX Designer", icon: Palette, color: "orange" },
|
||||
{ id: "web", label: "Web Dev Preview", icon: Monitor, color: "blue" },
|
||||
{ id: "app", label: "App Dev Preview", icon: Smartphone, color: "cyan" }
|
||||
];
|
||||
// --- Types ---
|
||||
|
||||
const AIAssist = () => {
|
||||
const { language, selectedProvider, selectedModels, setSelectedModel, apiKeys, aiAssistHistory, setAIAssistHistory } = useStore();
|
||||
interface PreviewData {
|
||||
type: string;
|
||||
data: string;
|
||||
language?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// --- Specialized Components ---
|
||||
|
||||
/**
|
||||
* A ultra-stable iframe wrapper that avoids hydration issues
|
||||
* and provides a WOW visual experience.
|
||||
*/
|
||||
const LiveCanvas = memo(({ data, type, isStreaming }: { data: string, type: string, isStreaming: boolean }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
|
||||
const isHtml = data.includes("<div") || data.includes("<section") || data.includes("class=");
|
||||
if (isHtml || type === "web" || type === "app" || type === "design") {
|
||||
const doc = `
|
||||
<!DOCTYPE html>
|
||||
<html class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #f1f5f9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100">
|
||||
${data}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
iframeRef.current.srcdoc = doc;
|
||||
}
|
||||
}, [data, type]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative group">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="Canvas Preview"
|
||||
className="w-full h-full border-none rounded-b-2xl bg-slate-950 shadow-inner"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
{isStreaming && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-indigo-500/20 overflow-hidden">
|
||||
<div className="h-full bg-indigo-500 animate-[loading_1.5s_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
<style jsx>{`
|
||||
@keyframes loading {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(200%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LiveCanvas.displayName = "LiveCanvas";
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
function parseStreamingContent(text: string) {
|
||||
let agent = "general";
|
||||
const agentMatch = text.match(/\[AGENT:(\w+)\]/);
|
||||
if (agentMatch) agent = agentMatch[1];
|
||||
|
||||
let preview: PreviewData | null = null;
|
||||
const previewMatch = text.match(/\[PREVIEW:(\w+):?(\w+)?\]([\s\S]*?)(?:\[\/PREVIEW\]|$)/);
|
||||
if (previewMatch) {
|
||||
preview = {
|
||||
type: previewMatch[1],
|
||||
language: previewMatch[2] || "text",
|
||||
data: previewMatch[3].trim(),
|
||||
isStreaming: !text.includes("[/PREVIEW]")
|
||||
};
|
||||
}
|
||||
|
||||
let chatDisplay = text
|
||||
.replace(/\[AGENT:\w+\]/g, "")
|
||||
.replace(/\[PREVIEW:\w+:?\w+?\][\s\S]*?(?:\[\/PREVIEW\]|$)/g, "")
|
||||
.trim();
|
||||
|
||||
if (!chatDisplay && preview) {
|
||||
chatDisplay = `Building visual artifact...`;
|
||||
}
|
||||
|
||||
return { chatDisplay, preview, agent };
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export default function AIAssist() {
|
||||
const {
|
||||
language,
|
||||
aiAssistHistory,
|
||||
setAIAssistHistory,
|
||||
selectedProvider,
|
||||
selectedModels,
|
||||
setSelectedModel
|
||||
} = useStore();
|
||||
const t = translations[language].aiAssist;
|
||||
const common = translations[language].common;
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [currentAgent, setCurrentAgent] = useState("general");
|
||||
const [activeTab, setActiveTab] = useState("chat");
|
||||
const [previewData, setPreviewData] = useState<{ type: string; data: string; language?: string } | null>(null);
|
||||
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [showCanvas, setShowCanvas] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll logic
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
scrollRef.current.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [aiAssistHistory]);
|
||||
}, [aiAssistHistory, isProcessing]);
|
||||
|
||||
// Load available models
|
||||
useEffect(() => {
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const models = await modelAdapter.listModels(selectedProvider);
|
||||
if (models.success && models.data) {
|
||||
setAvailableModels(models.data[selectedProvider] || []);
|
||||
const response = await modelAdapter.listModels(selectedProvider);
|
||||
if (response.success && response.data) {
|
||||
const models = response.data[selectedProvider] || [];
|
||||
setAvailableModels(models);
|
||||
if (models.length > 0 && !selectedModels[selectedProvider]) {
|
||||
setSelectedModel(selectedProvider, models[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
setAvailableModels(modelAdapter.getAvailableModels(selectedProvider));
|
||||
}
|
||||
};
|
||||
loadModels();
|
||||
}, [selectedProvider]);
|
||||
}, [selectedProvider, selectedModels, setSelectedModel]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!input.trim() || isProcessing) return;
|
||||
|
||||
const userMessage: AIAssistMessage = {
|
||||
const controller = new AbortController();
|
||||
setAbortController(controller);
|
||||
|
||||
const userMsg: AIAssistMessage = {
|
||||
role: "user",
|
||||
content: input,
|
||||
timestamp: new Date()
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setAIAssistHistory(prev => [...prev, userMessage]);
|
||||
const newHistory = [...aiAssistHistory, userMsg];
|
||||
setAIAssistHistory(newHistory);
|
||||
setInput("");
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const apiKey = apiKeys[selectedProvider];
|
||||
const isQwenOAuth = selectedProvider === "qwen" && modelAdapter.hasQwenAuth();
|
||||
|
||||
if (!isQwenOAuth && (!apiKey || !apiKey.trim())) {
|
||||
throw new Error(`Please configure your ${selectedProvider.toUpperCase()} API key in Settings`);
|
||||
}
|
||||
|
||||
// Convert history to clean message format for API
|
||||
const cleanMessages = aiAssistHistory.concat(userMessage).map(m => ({
|
||||
role: m.role,
|
||||
content: String(m.content || "")
|
||||
}));
|
||||
|
||||
// Call model adapter for AI Assist
|
||||
const result = await modelAdapter.generateAIAssist({
|
||||
messages: cleanMessages as any,
|
||||
currentAgent
|
||||
}, selectedProvider, selectedModels[selectedProvider]);
|
||||
|
||||
if (result.success && result.data) {
|
||||
try {
|
||||
// Expecting a structured response with possible agent switch and preview
|
||||
const cleanJson = result.data.replace(/```json\s*([\s\S]*?)\s*```/i, '$1').trim();
|
||||
const parsed = JSON.parse(cleanJson);
|
||||
|
||||
const assistantMessage: AIAssistMessage = {
|
||||
const assistantMsg: AIAssistMessage = {
|
||||
role: "assistant",
|
||||
content: parsed.content,
|
||||
agent: parsed.agent || currentAgent,
|
||||
preview: parsed.preview,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
if (parsed.agent && parsed.agent !== currentAgent) {
|
||||
setCurrentAgent(parsed.agent);
|
||||
}
|
||||
|
||||
if (parsed.preview) {
|
||||
setPreviewData(parsed.preview);
|
||||
setActiveTab("preview");
|
||||
}
|
||||
|
||||
setAIAssistHistory(prev => [...prev, assistantMessage]);
|
||||
} catch (e) {
|
||||
// Fallback to plain text if JSON parsing fails
|
||||
const assistantMessage: AIAssistMessage = {
|
||||
role: "assistant",
|
||||
content: result.data,
|
||||
content: "",
|
||||
agent: currentAgent,
|
||||
timestamp: new Date()
|
||||
};
|
||||
setAIAssistHistory(prev => [...prev, assistantMessage]);
|
||||
setAIAssistHistory([...newHistory, assistantMsg]);
|
||||
|
||||
try {
|
||||
let accumulated = "";
|
||||
let lastParsedPreview: PreviewData | null = null;
|
||||
|
||||
await modelAdapter.generateAIAssistStream(
|
||||
{
|
||||
messages: newHistory,
|
||||
currentAgent,
|
||||
onChunk: (chunk) => {
|
||||
accumulated += chunk;
|
||||
const { chatDisplay, preview, agent } = parseStreamingContent(accumulated);
|
||||
|
||||
// Only update preview state if it actually changed to avoid iframe jitters
|
||||
if (preview && JSON.stringify(preview) !== JSON.stringify(lastParsedPreview)) {
|
||||
setPreviewData(preview);
|
||||
lastParsedPreview = preview;
|
||||
setShowCanvas(true);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to get response");
|
||||
|
||||
if (agent !== currentAgent) setCurrentAgent(agent);
|
||||
|
||||
setAIAssistHistory(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === "assistant") {
|
||||
return [...prev.slice(0, -1), {
|
||||
...last,
|
||||
content: chatDisplay || accumulated,
|
||||
agent,
|
||||
preview: preview ? { type: preview.type, data: preview.data, language: preview.language } : undefined
|
||||
} as AIAssistMessage];
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage: AIAssistMessage = {
|
||||
role: "system",
|
||||
content: err instanceof Error ? err.message : "An unexpected error occurred",
|
||||
timestamp: new Date()
|
||||
};
|
||||
setAIAssistHistory(prev => [...prev, errorMessage]);
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
},
|
||||
selectedProvider,
|
||||
selectedModels[selectedProvider]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Assist error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setAbortController(null);
|
||||
}
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
setAbortController(null);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
setAIAssistHistory([]);
|
||||
setPreviewData(null);
|
||||
setActiveTab("chat");
|
||||
setCurrentAgent("general");
|
||||
setShowCanvas(false);
|
||||
};
|
||||
|
||||
const undoLast = () => {
|
||||
if (aiAssistHistory.length === 0) return;
|
||||
setAIAssistHistory(prev => prev.slice(0, -2));
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
if (!previewData) return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-400 gap-4">
|
||||
<Eye className="h-12 w-12 opacity-20" />
|
||||
<p className="text-sm font-medium italic">No active preview to display</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
switch (previewData.type) {
|
||||
case "web":
|
||||
case "app":
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-900 text-white shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-3.5 w-3.5 text-indigo-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Live Runtime Sandbox</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-rose-500" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-500" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-white overflow-hidden relative">
|
||||
<iframe
|
||||
title="web-preview"
|
||||
srcDoc={previewData.data}
|
||||
className="w-full h-full border-none"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-900 text-white rounded-t-xl shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-3.5 w-3.5 text-indigo-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Code Architect Output</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[8px] border-slate-700 text-slate-400 uppercase">{previewData.language || "source"}</Badge>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-950 rounded-b-xl overflow-auto p-6 font-mono text-sm relative group">
|
||||
<pre className="text-emerald-400 whitespace-pre leading-relaxed">{previewData.data}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "design":
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-900 text-white rounded-t-xl shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-3.5 w-3.5 text-orange-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">UI/UX Layout Frame</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-100/50 border-2 border-dashed border-slate-200 rounded-b-xl overflow-y-auto p-8">
|
||||
<div className="mx-auto max-w-[400px] w-full bg-white rounded-3xl shadow-2xl border border-slate-100 overflow-hidden animate-in zoom-in-95 duration-500">
|
||||
<div className="p-4 border-b bg-slate-50 flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
<div className="h-2 w-16 bg-slate-200 rounded-full" />
|
||||
</div>
|
||||
<div className="p-8 prose prose-slate">
|
||||
{previewData.data}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "seo":
|
||||
try {
|
||||
const seoData = JSON.parse(previewData.data);
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-6 p-6 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Object.entries(seoData.metrics || {}).map(([key, val]: [string, any]) => (
|
||||
<Card key={key} className="border-slate-100 shadow-sm bg-gradient-to-br from-white to-slate-50">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center text-center">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">{key}</span>
|
||||
<span className="text-2xl font-black text-indigo-600">{val}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-slate-800 flex items-center gap-2">
|
||||
<Search className="h-3.5 w-3.5 text-emerald-500" /> Recommendations
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{(seoData.recommendations || []).map((rec: string, i: number) => (
|
||||
<li key={i} className="flex gap-3 text-sm font-medium text-slate-600 bg-emerald-50/50 p-3 rounded-xl border border-emerald-100/50">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 bg-white rounded-xl border border-slate-200 prose prose-slate max-w-none">
|
||||
<div className="whitespace-pre-wrap font-medium text-slate-700 leading-relaxed">
|
||||
{previewData.data}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 bg-white rounded-xl border border-slate-200 prose prose-slate max-w-none">
|
||||
<div className="whitespace-pre-wrap font-medium text-slate-700 leading-relaxed">
|
||||
{previewData.data}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const getAgentIcon = (agent: string) => {
|
||||
switch (agent.toLowerCase()) {
|
||||
case 'code': return <Code2 className="h-4 w-4" />;
|
||||
case 'design': return <Palette className="h-4 w-4" />;
|
||||
case 'seo': return <TargetIcon className="h-4 w-4" />;
|
||||
case 'research': return <Search className="h-4 w-4" />;
|
||||
default: return <Sparkles className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-140px)] flex flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col gap-2 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-200">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
<div className="h-[calc(100vh-140px)] flex gap-4 lg:gap-8 overflow-hidden animate-in fade-in duration-700">
|
||||
{/* --- Chat Panel --- */}
|
||||
<div className={cn(
|
||||
"flex flex-col h-full transition-all duration-700 cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
showCanvas ? "w-2/5 min-w-[400px]" : "w-full max-w-4xl mx-auto"
|
||||
)}>
|
||||
<Card className="flex-1 flex flex-col border border-slate-200/40 dark:border-slate-800/60 shadow-[0_8px_32px_rgba(0,0,0,0.12)] bg-white/70 dark:bg-slate-900/40 backdrop-blur-2xl rounded-[2rem] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-slate-200/60 dark:border-slate-800/40 flex items-center justify-between shrink-0 bg-slate-50/30 dark:bg-slate-900/20 backdrop-blur-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="p-2.5 bg-gradient-to-tr from-indigo-500 to-violet-600 rounded-2xl text-white shadow-lg shadow-indigo-500/20">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-emerald-500 border-2 border-white dark:border-slate-900 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-black tracking-tight text-slate-900">{t.title}</h2>
|
||||
<p className="text-slate-500 font-medium text-xs">{t.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedModels[selectedProvider]}
|
||||
onChange={(e) => setSelectedModel(selectedProvider, e.target.value)}
|
||||
className="h-9 w-[180px] text-xs rounded-xl border-slate-200 bg-white"
|
||||
>
|
||||
{availableModels.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={undoLast} disabled={aiAssistHistory.length === 0} className="rounded-xl border-slate-200 text-slate-500 hover:text-amber-500 hover:border-amber-200">
|
||||
<RotateCcw className="h-4 w-4 mr-2" /> Undo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={clearHistory} className="rounded-xl border-slate-200 text-slate-500 hover:text-rose-500 hover:border-rose-200">
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 xl:grid-cols-12 gap-8 min-h-0">
|
||||
{/* Chat Panel */}
|
||||
<Card className={cn(
|
||||
"xl:col-span-12 flex flex-col border-slate-200/60 shadow-xl shadow-slate-200/40 overflow-hidden bg-white/80 backdrop-blur-md transition-all duration-500",
|
||||
activeTab === "preview" ? "xl:col-span-5" : "xl:col-span-12"
|
||||
)}>
|
||||
<CardHeader className="bg-slate-50/50 border-b p-4 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-indigo-600" />
|
||||
<span className="text-xs font-black uppercase tracking-widest text-slate-500">Conversation Thread</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{AGENTS.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => setCurrentAgent(agent.id)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-lg transition-all",
|
||||
currentAgent === agent.id
|
||||
? `bg-${agent.color}-100 text-${agent.color}-600 ring-2 ring-${agent.color}-400/30 scale-110`
|
||||
: "text-slate-400 hover:text-slate-600 hover:bg-slate-100"
|
||||
)}
|
||||
title={agent.label}
|
||||
>
|
||||
<agent.icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col p-0 min-h-0 relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-6 space-y-6 scroll-smooth"
|
||||
>
|
||||
{aiAssistHistory.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-12">
|
||||
<div className="p-6 rounded-3xl bg-indigo-50 border border-indigo-100 mb-6 animate-bounce duration-[3000ms]">
|
||||
<Sparkles className="h-12 w-12 text-indigo-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-800 mb-2">{t.chatStart}</h3>
|
||||
<p className="text-sm text-slate-400 max-w-sm">Start a conversation to activate specialized AI agents for code, design, SEO, and more.</p>
|
||||
</div>
|
||||
) : (
|
||||
aiAssistHistory.map((msg, i) => (
|
||||
<div key={i} className={cn(
|
||||
"flex gap-4 animate-in fade-in slide-in-from-bottom-2 duration-500",
|
||||
msg.role === "user" ? "flex-row-reverse" : "flex-row"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"h-9 w-9 shrink-0 rounded-2xl flex items-center justify-center border shadow-sm transition-transform hover:scale-110",
|
||||
msg.role === "user"
|
||||
? "bg-slate-900 border-slate-800 text-white"
|
||||
: "bg-white border-slate-100 text-indigo-600"
|
||||
)}>
|
||||
{msg.role === "user" ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"max-w-[80%] space-y-2 group/msg",
|
||||
msg.role === "user" ? "items-end text-right" : "items-start text-left"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{msg.role === "user" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setInput(msg.content);
|
||||
setAIAssistHistory(prev => prev.slice(0, i));
|
||||
}}
|
||||
className="opacity-0 group-hover/msg:opacity-100 p-1 hover:bg-slate-100 rounded text-slate-400 transition-all"
|
||||
title="Revise from here"
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{msg.agent && (
|
||||
<Badge variant="outline" className="text-[9px] font-black uppercase tracking-widest px-1.5 py-0 border-indigo-200 text-indigo-500 bg-indigo-50/50">
|
||||
{AGENTS.find(a => a.id === msg.agent)?.label || msg.agent}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"p-4 rounded-3xl text-sm font-medium leading-relaxed shadow-sm",
|
||||
msg.role === "user"
|
||||
? "bg-indigo-600 text-white rounded-tr-none"
|
||||
: msg.role === "system"
|
||||
? "bg-rose-50 text-rose-600 border border-rose-100 rounded-tl-none"
|
||||
: "bg-white border border-slate-100 text-slate-700 rounded-tl-none"
|
||||
)}>
|
||||
{msg.content}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-bold px-1">
|
||||
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
<h2 className="text-xl font-black text-slate-800 dark:text-slate-100 tracking-tight">{t.title}</h2>
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
<span className="text-[10px] font-bold text-indigo-500 uppercase tracking-widest animate-in slide-in-from-left-4">
|
||||
Active: {currentAgent}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isProcessing && (
|
||||
<div className="flex gap-4 animate-pulse">
|
||||
<div className="h-9 w-9 rounded-2xl bg-slate-100 border border-slate-50 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-indigo-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 w-32 bg-slate-50 border border-slate-100 rounded-3xl rounded-tl-none flex items-center px-4">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
|
||||
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
|
||||
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-6 bg-white border-t border-slate-100">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-x-0 -top-12 h-12 bg-gradient-to-t from-white to-transparent pointer-events-none" />
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSendMessage()}
|
||||
placeholder={t.placeholder}
|
||||
className="h-14 pl-12 pr-4 bg-slate-50 border-slate-200 focus:bg-white focus:ring-4 focus:ring-indigo-500/10 transition-all rounded-2xl font-medium"
|
||||
/>
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2">
|
||||
<Sparkles className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={isProcessing || !input.trim()}
|
||||
className="h-14 w-14 rounded-2xl bg-indigo-600 hover:bg-indigo-700 text-white shadow-lg shadow-indigo-100 shrink-0 transition-transform active:scale-90"
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedModels[selectedProvider]}
|
||||
onChange={(e) => setSelectedModel(selectedProvider, e.target.value)}
|
||||
className="text-[11px] font-black h-9 px-3 rounded-xl border-slate-200 dark:border-slate-800 bg-white/50 dark:bg-slate-950/40 focus:ring-2 focus:ring-indigo-500/40 transition-all outline-none"
|
||||
>
|
||||
<Send className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between px-2">
|
||||
<div className="flex gap-4">
|
||||
<button className="text-[10px] font-black uppercase tracking-widest text-slate-400 hover:text-indigo-500 transition-colors flex items-center gap-1.5">
|
||||
<Layers className="h-3 w-3" /> Layout Design
|
||||
</button>
|
||||
<button className="text-[10px] font-black uppercase tracking-widest text-slate-400 hover:text-indigo-500 transition-colors flex items-center gap-1.5">
|
||||
<Code2 className="h-3 w-3" /> Code Snippet
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-slate-300 italic">
|
||||
Powered by {selectedProvider.toUpperCase()} / {selectedModels[selectedProvider]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview Panel (Conditional) */}
|
||||
{activeTab === "preview" && (
|
||||
<Card className="xl:col-span-7 flex flex-col border-slate-200/60 shadow-2xl shadow-slate-200/50 overflow-hidden bg-white animate-in slide-in-from-right-8 duration-500">
|
||||
<CardHeader className="bg-slate-900 text-white p-4 shrink-0 flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-indigo-400" />
|
||||
<span className="text-xs font-black uppercase tracking-widest">{t.preview}</span>
|
||||
</div>
|
||||
{availableModels.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setActiveTab("chat")}
|
||||
className="h-8 w-8 text-slate-400 hover:text-white"
|
||||
onClick={clearHistory}
|
||||
className="h-9 w-9 text-slate-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-8 space-y-8 scrollbar-thin scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-800">
|
||||
{aiAssistHistory.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-20 animate-in zoom-in-95 duration-500">
|
||||
<div className="p-8 bg-indigo-500/5 dark:bg-indigo-500/10 rounded-full mb-8 relative">
|
||||
<Ghost className="h-20 w-20 text-indigo-400/40 animate-bounce duration-[3s]" />
|
||||
<div className="absolute inset-0 bg-indigo-500/10 blur-3xl rounded-full" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-black text-slate-800 dark:text-slate-100 mb-3 tracking-tighter">Your AI Workspace</h3>
|
||||
<p className="max-w-xs text-sm font-medium text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
I switch agents and render visual artifacts automatically. How can I help today?
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-3">
|
||||
{['Build a UI', 'SEO Audit', 'App Design'].map(chip => (
|
||||
<Badge key={chip} variant="secondary" className="px-4 py-2 rounded-full cursor-pointer hover:bg-indigo-500 hover:text-white transition-all text-[11px] font-black border-transparent shadow-sm">
|
||||
{chip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiAssistHistory.map((msg, i) => (
|
||||
<div key={i} className={cn(
|
||||
"flex flex-col gap-3 group animate-in slide-in-from-bottom-4 duration-500",
|
||||
msg.role === "user" ? "items-end" : "items-start"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"max-w-[90%] p-5 rounded-3xl relative transition-all duration-300",
|
||||
msg.role === "user"
|
||||
? "bg-gradient-to-br from-indigo-600 to-violet-700 text-white rounded-tr-none shadow-[0_8px_20px_rgba(99,102,241,0.25)]"
|
||||
: "bg-white dark:bg-slate-800/80 border border-slate-200/60 dark:border-slate-700/50 text-slate-700 dark:text-slate-200 rounded-tl-none shadow-sm backdrop-blur-xl"
|
||||
)}>
|
||||
<div className="absolute top-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => navigator.clipboard.writeText(msg.content)} className="text-inherit opacity-40 hover:opacity-100">
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none leading-relaxed font-medium">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
||||
{msg.content || (msg.role === "assistant" ? "..." : "")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{msg.role === "assistant" && msg.preview && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-5 w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200/50 dark:border-white/5 text-indigo-500 dark:text-indigo-400 font-black uppercase tracking-[0.1em] text-[10px] rounded-2xl h-11 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
onClick={() => {
|
||||
setPreviewData({ ...msg.preview!, isStreaming: false });
|
||||
setShowCanvas(true);
|
||||
}}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5 mr-2" /> Activate Artifact
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 overflow-hidden bg-slate-50/50">
|
||||
{renderPreview()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-tighter">
|
||||
{msg.role === "assistant" ? `Neural • ${msg.agent || 'core'}` : 'Explorer'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-6 bg-slate-50/40 dark:bg-slate-900/20 border-t border-slate-200/60 dark:border-slate-800/40 shrink-0">
|
||||
<form onSubmit={handleSendMessage} className="relative group">
|
||||
<div className="absolute inset-0 bg-indigo-500/5 rounded-[1.5rem] blur-xl group-focus-within:bg-indigo-500/10 transition-all" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t.placeholder}
|
||||
disabled={isProcessing}
|
||||
className="relative pr-24 py-7 rounded-[1.5rem] bg-white/80 dark:bg-slate-950/60 border-slate-200/80 dark:border-slate-800/80 shadow-lg shadow-indigo-500/5 focus:ring-4 focus:ring-indigo-500/10 transition-all font-medium text-base h-16 outline-none"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={stopGeneration}
|
||||
className="h-10 w-10 p-0 rounded-2xl bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white animate-in zoom-in-75 transition-all"
|
||||
>
|
||||
<StopCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="h-11 w-11 rounded-2xl bg-indigo-600 shadow-lg shadow-indigo-600/30 hover:scale-105 active:scale-95 transition-all p-0"
|
||||
>
|
||||
<Send className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</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-slate-950 rounded-[2.5rem] overflow-hidden border border-slate-800 shadow-[0_20px_80px_rgba(0,0,0,0.6)]">
|
||||
<div className="px-6 py-5 border-b border-slate-800/60 bg-slate-900/50 backdrop-blur-2xl flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 bg-indigo-500/10 rounded-2xl border border-indigo-500/20">
|
||||
{viewMode === "preview" ? <Monitor className="h-5 w-5 text-indigo-400" /> : <Code2 className="h-5 w-5 text-emerald-400" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-white uppercase tracking-[0.2em]">{previewData?.type} Canvas</h3>
|
||||
<div className="flex bg-slate-800/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-indigo-500 text-white shadow-lg" : "text-slate-500 hover:text-slate-300")}
|
||||
>
|
||||
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-indigo-500 text-white shadow-lg" : "text-slate-500 hover:text-slate-300")}
|
||||
>
|
||||
Inspect Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 text-slate-400 hover:text-white hover:bg-slate-800 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-slate-400 hover:text-rose-500 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-slate-800">
|
||||
<pre className="text-emerald-400/90 leading-relaxed selection:bg-emerald-500/20 whitespace-pre-wrap">
|
||||
<code>{previewData?.data}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-t border-slate-800/40 bg-slate-900/30 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-emerald-500")} />
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest leading-none">
|
||||
{previewData?.isStreaming ? "Neural Link Active" : "Sync Complete"}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] border-slate-800 text-slate-500 font-black">
|
||||
{previewData?.language?.toUpperCase()} • UTF-8
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default AIAssist;
|
||||
// Custom simple icon for Target/SEO
|
||||
function TargetIcon(props: any) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
57
components/ErrorBoundary.tsx
Normal file
57
components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
resetError = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 bg-slate-50 border border-slate-200 rounded-2xl h-full text-center">
|
||||
<div className="bg-rose-100 p-3 rounded-full mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-rose-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-2">Something went wrong</h3>
|
||||
<p className="text-sm text-slate-500 max-w-xs mb-6">
|
||||
{this.state.error?.message || "An unexpected error occurred while rendering this component."}
|
||||
</p>
|
||||
<Button onClick={this.resetError} variant="outline">
|
||||
<RotateCcw className="h-4 w-4 mr-2" /> Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,35 @@ export class ModelAdapter {
|
||||
return this.callWithFallback((service) => service.generateAIAssist(options, model), providers);
|
||||
}
|
||||
|
||||
async generateAIAssistStream(
|
||||
options: {
|
||||
messages: AIAssistMessage[];
|
||||
currentAgent: string;
|
||||
onChunk: (chunk: string) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
provider?: ModelProvider,
|
||||
model?: string
|
||||
): Promise<APIResponse<void>> {
|
||||
const fallback = this.buildFallbackProviders(this.preferredProvider, "qwen", "ollama", "zai");
|
||||
const providers: ModelProvider[] = provider ? [provider] : fallback;
|
||||
|
||||
// For now we don't handle fallback for streaming strictly, just use first available
|
||||
const activeProvider = providers[0];
|
||||
let service: any;
|
||||
switch (activeProvider) {
|
||||
case "qwen": service = this.qwenService; break;
|
||||
case "ollama": service = this.ollamaService; break;
|
||||
case "zai": service = this.zaiService; break;
|
||||
}
|
||||
|
||||
if (!service || !service.generateAIAssistStream) {
|
||||
return { success: false, error: "Streaming not supported for this provider" };
|
||||
}
|
||||
|
||||
return await service.generateAIAssistStream(options, model);
|
||||
}
|
||||
|
||||
|
||||
async chatCompletion(
|
||||
messages: ChatMessage[],
|
||||
|
||||
@@ -730,6 +730,97 @@ Perform a DEEP 360° competitive intelligence analysis and generate 5-7 strategi
|
||||
|
||||
return await this.chatCompletion(chatMessages, model || this.getAvailableModels()[0]);
|
||||
}
|
||||
|
||||
async generateAIAssistStream(
|
||||
options: {
|
||||
messages: AIAssistMessage[];
|
||||
currentAgent: string;
|
||||
onChunk: (chunk: string) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
model?: string
|
||||
): Promise<APIResponse<void>> {
|
||||
try {
|
||||
// ... existing prompt logic ...
|
||||
const systemPrompt = `You are "AI Assist", the master orchestrator.
|
||||
Your goal is to provide intelligent conversational support and switch to specialized agents.
|
||||
|
||||
CANVAS MODE (CRITICAL):
|
||||
When the user asks to "build", "design", "create", or "write code", you MUST use the [PREVIEW] tag.
|
||||
Inside [PREVIEW], output ONLY the actual functional code (HTML/Tailwind, Javascript, etc.).
|
||||
Do NOT explain what the code does inside the bubble if you are generating a preview.
|
||||
The user wants to see it WORKING in the Canvas immediately.
|
||||
|
||||
STRICT OUTPUT FORMAT:
|
||||
[AGENT:id] - Optional: switch to content, seo, smm, pm, code, design, web, app.
|
||||
[PREVIEW:type:language]
|
||||
ACTUAL_FUNCTIONAL_CODE_OR_DATA
|
||||
[/PREVIEW]
|
||||
Optional conversational text (keep it brief).
|
||||
|
||||
Example for a mockup:
|
||||
[AGENT:design]
|
||||
[PREVIEW:design:html]
|
||||
<div class="bg-blue-500 p-10">...</div>
|
||||
[/PREVIEW]`;
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...options.messages.map(m => ({
|
||||
role: m.role as "user" | "assistant" | "system",
|
||||
content: m.content
|
||||
}))
|
||||
];
|
||||
|
||||
const response = await fetch(LOCAL_CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders({ "Content-Type": "application/json" }),
|
||||
signal: options.signal,
|
||||
body: JSON.stringify({
|
||||
model: model || this.getAvailableModels()[0],
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Stream request failed");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No reader");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.message?.content) {
|
||||
options.onChunk(data.message.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream line", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: undefined };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Stream failed" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default OllamaCloudService;
|
||||
|
||||
@@ -1006,6 +1006,107 @@ Perform analysis based on provided instructions.`,
|
||||
return await this.chatCompletion(chatMessages, model || this.getAvailableModels()[0]);
|
||||
}
|
||||
|
||||
async generateAIAssistStream(
|
||||
options: {
|
||||
messages: AIAssistMessage[];
|
||||
currentAgent: string;
|
||||
onChunk: (chunk: string) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
model?: string
|
||||
): Promise<APIResponse<void>> {
|
||||
try {
|
||||
// ... existing prompt logic ...
|
||||
const systemPrompt = `You are "AI Assist".
|
||||
Your goal is to provide intelligent support with a "Canvas" experience.
|
||||
|
||||
CANVAS MODE (CRITICAL):
|
||||
When building or designing, you MUST use the [PREVIEW] tag.
|
||||
Inside [PREVIEW], output ONLY the actual code (HTML/Tailwind etc).
|
||||
The user wants to see it WORKING in the Canvas immediately.
|
||||
|
||||
STRICT OUTPUT FORMAT:
|
||||
[AGENT:id] - Optional: content, seo, smm, pm, code, design, web, app.
|
||||
[PREVIEW:type:language]
|
||||
ACTUAL_FUNCTIONAL_CODE
|
||||
[/PREVIEW]
|
||||
Optional brief text.`;
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...options.messages.map(m => ({
|
||||
role: m.role as "user" | "assistant" | "system",
|
||||
content: m.content
|
||||
}))
|
||||
];
|
||||
|
||||
const endpoint = "/tools/promptarch/api/qwen/chat";
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const tokenInfo = this.getTokenInfo();
|
||||
if (tokenInfo?.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${tokenInfo.accessToken}`;
|
||||
} else if (this.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: options.signal,
|
||||
body: JSON.stringify({
|
||||
model: model || this.getAvailableModels()[0],
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Stream request failed");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No reader");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || !trimmedLine.startsWith("data:")) continue;
|
||||
|
||||
const dataStr = trimmedLine.replace(/^data:\s*/, "");
|
||||
if (dataStr === "[DONE]") break;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
if (data.choices?.[0]?.delta?.content) {
|
||||
options.onChunk(data.choices[0].delta.content);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors for incomplete lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: undefined };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Stream failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<APIResponse<string[]>> {
|
||||
const models = [
|
||||
"coder-model",
|
||||
|
||||
@@ -799,6 +799,88 @@ MISSION: Perform a DEEP 360° competitive intelligence analysis and generate 5-7
|
||||
|
||||
return await this.chatCompletion(chatMessages, model || this.getAvailableModels()[0]);
|
||||
}
|
||||
|
||||
async generateAIAssistStream(
|
||||
options: {
|
||||
messages: AIAssistMessage[];
|
||||
currentAgent: string;
|
||||
onChunk: (chunk: string) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
model?: string
|
||||
): Promise<APIResponse<void>> {
|
||||
try {
|
||||
if (!this.config.apiKey) {
|
||||
throw new Error("API key is required.");
|
||||
}
|
||||
|
||||
// ... existing prompt logic ...
|
||||
const systemPrompt = `You are "AI Assist".
|
||||
Your goal is to provide a "Canvas" experience.
|
||||
|
||||
CANVAS MODE (CRITICAL):
|
||||
When building or designing, you MUST use the [PREVIEW] tag.
|
||||
Inside [PREVIEW], output ONLY the actual code (HTML/Tailwind etc).
|
||||
The user wants to see it WORKING in the Canvas immediately.
|
||||
|
||||
STRICT OUTPUT FORMAT:
|
||||
[AGENT:id] - Optional switch.
|
||||
[PREVIEW:type:language]
|
||||
ACTUAL_FUNCTIONAL_CODE
|
||||
[/PREVIEW]
|
||||
Optional brief text.`;
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...options.messages.map(m => ({
|
||||
role: m.role as "user" | "assistant" | "system",
|
||||
content: m.content
|
||||
}))
|
||||
];
|
||||
|
||||
const endpoint = this.config.codingEndpoint; // AI Assist often involves coding
|
||||
const response = await fetch(`${endpoint}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
signal: options.signal,
|
||||
body: JSON.stringify({
|
||||
model: model || this.getAvailableModels()[0],
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Stream failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No reader");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith("data:")) continue;
|
||||
const dataStr = line.replace(/^data:\s*/, "");
|
||||
if (dataStr === "[DONE]") break;
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
const content = data.choices?.[0]?.delta?.content || data.output?.choices?.[0]?.delta?.content;
|
||||
if (content) options.onChunk(content);
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: undefined };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Stream failed" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ZaiPlanService;
|
||||
|
||||
@@ -297,7 +297,7 @@ export interface AIAssistMessage {
|
||||
content: string;
|
||||
agent?: string;
|
||||
preview?: {
|
||||
type: "code" | "design" | "content" | "seo";
|
||||
type: string;
|
||||
data: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user