Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useState, useRef, useEffect } from "react"; | |
| import { ChatMessage, sendChatMessage, getChatHistory } from "../api/client"; | |
| import { useAuth } from "../hooks/useAuth"; | |
| import { isPaidPlan } from "../lib/plan"; | |
| import UpgradeModal from "./UpgradeModal"; | |
| interface Props { | |
| projectId: number; | |
| onScenesUpdated: () => void; | |
| } | |
| export default function ChatPanel({ projectId, onScenesUpdated }: Props) { | |
| const { user } = useAuth(); | |
| const isPro = isPaidPlan(user?.plan); | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [input, setInput] = useState(""); | |
| const [loading, setLoading] = useState(false); | |
| const [showUpgrade, setShowUpgrade] = useState(false); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| useEffect(() => { | |
| loadHistory(); | |
| }, [projectId]); | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages]); | |
| const loadHistory = async () => { | |
| try { | |
| const res = await getChatHistory(projectId); | |
| setMessages(res.data); | |
| } catch { | |
| // No history yet | |
| } | |
| }; | |
| const handleSend = async () => { | |
| if (!isPro) { | |
| setShowUpgrade(true); | |
| return; | |
| } | |
| const msg = input.trim(); | |
| if (!msg || loading) return; | |
| const userMsg: ChatMessage = { | |
| id: Date.now(), | |
| role: "user", | |
| content: msg, | |
| created_at: new Date().toISOString(), | |
| }; | |
| setMessages((prev) => [...prev, userMsg]); | |
| setInput(""); | |
| setLoading(true); | |
| try { | |
| const res = await sendChatMessage(projectId, msg); | |
| const assistantMsg: ChatMessage = { | |
| id: Date.now() + 1, | |
| role: "assistant", | |
| content: res.data.reply, | |
| created_at: new Date().toISOString(), | |
| }; | |
| setMessages((prev) => [...prev, assistantMsg]); | |
| onScenesUpdated(); | |
| } catch (err: any) { | |
| const errorMsg: ChatMessage = { | |
| id: Date.now() + 1, | |
| role: "assistant", | |
| content: `Error: ${err?.response?.data?.detail || "Failed to process your request."}`, | |
| created_at: new Date().toISOString(), | |
| }; | |
| setMessages((prev) => [...prev, errorMsg]); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }; | |
| const handleInputFocus = () => { | |
| if (!isPro) { | |
| setShowUpgrade(true); | |
| } | |
| }; | |
| return ( | |
| <> | |
| <div className="flex flex-col h-full glass-card overflow-hidden"> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto p-4 space-y-3"> | |
| {messages.length === 0 && ( | |
| <div className="text-center text-gray-400 py-8"> | |
| <p className="text-sm font-medium mb-1">Edit with AI</p> | |
| <p className="text-xs text-gray-300"> | |
| "Make scene 2 more dramatic" or "Add an introduction" | |
| </p> | |
| </div> | |
| )} | |
| {messages.map((msg) => ( | |
| <div | |
| key={msg.id} | |
| className={`flex ${ | |
| msg.role === "user" ? "justify-end" : "justify-start" | |
| }`} | |
| > | |
| <div | |
| className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${ | |
| msg.role === "user" | |
| ? "bg-purple-600 text-white" | |
| : "glass text-gray-700" | |
| }`} | |
| > | |
| <p className="whitespace-pre-wrap">{msg.content}</p> | |
| </div> | |
| </div> | |
| ))} | |
| {loading && ( | |
| <div className="flex justify-start"> | |
| <div className="glass rounded-2xl px-4 py-2.5"> | |
| <div className="flex items-center gap-1.5"> | |
| <span className="w-1.5 h-1.5 bg-purple-400 rounded-full animate-pulse" /> | |
| <span className="w-1.5 h-1.5 bg-purple-400 rounded-full animate-pulse delay-100" /> | |
| <span className="w-1.5 h-1.5 bg-purple-400 rounded-full animate-pulse delay-200" /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* Input */} | |
| <div className="border-t border-gray-200/30 p-3"> | |
| {!isPro && ( | |
| <button | |
| onClick={() => setShowUpgrade(true)} | |
| className="w-full mb-2 px-3 py-2.5 bg-gradient-to-r from-purple-50 to-violet-50 hover:from-purple-100 hover:to-violet-100 rounded-lg transition-colors text-left group" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-[11px] font-semibold text-purple-600"> | |
| Upgrade to Pro | |
| </p> | |
| <p className="text-[10px] text-purple-400"> | |
| AI chat editing is a Pro feature | |
| </p> | |
| </div> | |
| <svg className="w-4 h-4 text-purple-400 group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> | |
| </svg> | |
| </div> | |
| </button> | |
| )} | |
| <div className="flex gap-2"> | |
| <textarea | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| onFocus={handleInputFocus} | |
| placeholder={isPro ? "Type your edit request..." : "Upgrade to Pro to edit..."} | |
| rows={1} | |
| disabled={!isPro} | |
| className="flex-1 px-4 py-2 bg-white/80 border border-gray-200/40 rounded-xl text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500/40 resize-none disabled:opacity-50 disabled:cursor-not-allowed" | |
| /> | |
| <button | |
| onClick={handleSend} | |
| disabled={loading || !input.trim() || !isPro} | |
| className="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-100 disabled:text-gray-400 text-white text-xs font-medium rounded-xl transition-colors" | |
| > | |
| Send | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <UpgradeModal | |
| open={showUpgrade} | |
| onClose={() => setShowUpgrade(false)} | |
| feature="AI Chat Editor" | |
| /> | |
| </> | |
| ); | |
| } | |