3a94e7c003
Security (critical):
- SESSION_SECRET fail-fast: refuse to boot without a 32+ char secret
(src/lib/session.ts, src/app/actions/clientAuth.ts)
- Rate limit with pluggable backend: in-memory by default, auto-promotes
to Upstash Redis when REDIS_URL is set (src/lib/rateLimit.ts)
- CSRF (double-submit HMAC) + Zod validation on /api/consultation;
new /api/csrf endpoint mints tokens (src/lib/csrf.ts)
- escapeHtml + safeMailto helpers; consultation email template now
fully escapes user-controlled fields (src/lib/escapeHtml.ts)
- Magic-byte validation for /api/public-upload — rejects HTML/JS
payloads renamed to .png/.mp4 (src/lib/fileType.ts)
- Nginx: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy + 5r/m upload zone for /api/public-upload and
/api/assets (nginx/conf.d/flux.conf)
Quality:
- Delete GlobalOperations_old.tsx dead code (310 LOC)
- NavBar: replace 2s session polling with CustomEvent("flux:session-
changed") + visibilitychange listener (no more interval leaks)
- Type-safe CMS shapes via src/types/cms.ts (replaces any[] in
ApplicationsDashboard + GlobalOperations)
- /api/health now pings Postgres; docker-compose healthcheck added
- Structured JSON logger (src/lib/logger.ts) — drop-in replacement
for console.error across API routes
- Prisma indices on isActive/category/nodeType filters
FluxAI persistence + analytics:
- New models AiConversation + AiEvent with funnel stage detection
(DISCOVERY -> QUALIFY -> RECOMMEND -> HANDOFF) and OperationsSignal
back-ref so converted chats link to their consultation ticket
- /api/chat persists every user msg, ai msg, tool call, tool result;
IP is sha256-hashed with SESSION_SECRET salt; promptCacheKey wired
for when @ai-sdk/openai lands the feature
- New HQ dashboard at /hq-command/dashboard/conversations: 4 KPIs
(total, conversion rate, avg messages, avg tools), funnel + industry
breakdowns, last-50 table, per-id transcript with tool timeline
- SilentObserver sends sessionId/locale/pageUrl in transport body so
the route can stitch messages into the same conversation
- src/lib/aiSessionId.ts: localStorage UUID with sessionStorage +
in-memory fallbacks for privacy mode
- Golden tests via node --test (13 cases, no new deps);
npm run test:ai
Migration:
- prisma/migrations/20260526180000_add_indexes_and_ai_telemetry —
additive only, IF NOT EXISTS guards, safe for migrate deploy
env template hardened: SESSION_SECRET documented as required + how
to generate; REDIS_URL/REDIS_TOKEN documented as opt-in for multi-
instance deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
420 lines
27 KiB
TypeScript
420 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Sparkles, ArrowRight, X, Minus, Database, Maximize2, Minimize2 } from "lucide-react";
|
|
import { useChat } from "@ai-sdk/react";
|
|
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
|
import { useUIStore } from "@/lib/store/uiStore";
|
|
import { useRouter, usePathname } from "next/navigation";
|
|
import { useState, useEffect, useRef, useMemo } from "react";
|
|
|
|
// ── Renderers ──
|
|
import MarkdownRenderer from "./MarkdownRenderer";
|
|
import EnergySavingsCalculator from "./EnergySavingsCalculator";
|
|
import ProcessComparisonTable from "./ProcessComparisonTable";
|
|
import RFTechExplainer from "./RFTechExplainer";
|
|
import ConsultationScheduler from "./ConsultationScheduler";
|
|
import CaseStudyViewer from "./CaseStudyViewer";
|
|
import EquipmentConfigurator from "./EquipmentConfigurator";
|
|
import EfficiencyCard from "./EfficiencyCard";
|
|
|
|
import { getAiSessionId } from "@/lib/aiSessionId";
|
|
|
|
export default function SilentObserver() {
|
|
const {
|
|
isAiExpanded, toggleAi, setAiExpanded,
|
|
currentSection, activeApplicationTab, setActiveApplicationTab,
|
|
setHighlightedMapNode, setSelectedMarkerId,
|
|
} = useUIStore();
|
|
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const locale = pathname?.split('/')[1] || 'en';
|
|
|
|
const [input, setInput] = useState("");
|
|
const [isDark, setIsDark] = useState(false);
|
|
const [isWideMode, setIsWideMode] = useState(false);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Refs for dynamic body (accessed inside transport function)
|
|
const sectionRef = useRef(currentSection);
|
|
const tabRef = useRef(activeApplicationTab);
|
|
useEffect(() => { sectionRef.current = currentSection; }, [currentSection]);
|
|
useEffect(() => { tabRef.current = activeApplicationTab; }, [activeApplicationTab]);
|
|
|
|
useEffect(() => {
|
|
const checkTheme = () => setIsDark(document.documentElement.classList.contains("dark"));
|
|
checkTheme();
|
|
const observer = new MutationObserver(checkTheme);
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const handleClose = () => {
|
|
setAiExpanded(false);
|
|
setTimeout(() => setIsWideMode(false), 400);
|
|
};
|
|
|
|
// ═══ AI SDK 6: Transport with dynamic body ═══
|
|
// sessionId is stable per visitor (localStorage UUID) so the chat route can
|
|
// stitch all messages into the same AiConversation row for analytics.
|
|
const transport = useMemo(() => new DefaultChatTransport({
|
|
api: "/api/chat",
|
|
body: () => ({
|
|
sessionId: getAiSessionId(),
|
|
locale,
|
|
pageUrl: typeof window !== "undefined" ? window.location.href : null,
|
|
context: {
|
|
section: sectionRef.current,
|
|
activeTab: tabRef.current,
|
|
},
|
|
}),
|
|
}), [locale]);
|
|
|
|
// ═══ AI SDK 6: useChat ═══
|
|
const { messages, sendMessage, addToolOutput, status } = useChat({
|
|
transport,
|
|
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
|
|
|
async onToolCall({ toolCall }) {
|
|
if (toolCall.dynamic) return;
|
|
|
|
if (toolCall.toolName === "navigate_to_section") {
|
|
const { section, url, subAction, tabId, nodeId } = toolCall.input as {
|
|
section?: string; url?: string; subAction?: string; tabId?: string; nodeId?: string;
|
|
};
|
|
handleClose();
|
|
|
|
// Valid homepage DOM IDs — anything else is a page route
|
|
const HOMEPAGE_IDS = new Set([
|
|
"technology", "applications-dashboard", "applications-deep",
|
|
"global", "our-story", "legacy",
|
|
]);
|
|
|
|
// Fallback map: if the AI sends a section name that's actually a page
|
|
const SECTION_TO_PAGE: Record<string, string> = {
|
|
news: "/news", heritage: "/heritage", parts: "/parts",
|
|
"parts-catalog": "/parts", contact: "/parts",
|
|
"inside-flux": "/news", "spare-parts": "/parts",
|
|
};
|
|
|
|
// Resolve: explicit url > section-to-page fallback > homepage scroll
|
|
const resolvedUrl = url
|
|
|| (section && !HOMEPAGE_IDS.has(section) ? SECTION_TO_PAGE[section] || null : null);
|
|
|
|
if (resolvedUrl) {
|
|
// Cross-page navigation
|
|
setTimeout(() => {
|
|
router.push(`/${locale}${resolvedUrl}`);
|
|
}, 400);
|
|
addToolOutput({
|
|
tool: "navigate_to_section" as any,
|
|
toolCallId: toolCall.toolCallId,
|
|
output: `Navigated to page "${resolvedUrl}"`,
|
|
});
|
|
} else if (section && HOMEPAGE_IDS.has(section)) {
|
|
// Same-page scroll — only for confirmed homepage DOM IDs
|
|
setTimeout(() => {
|
|
const el = document.getElementById(section);
|
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
if (subAction === "activate-tab" && tabId) setActiveApplicationTab(tabId);
|
|
if (subAction === "highlight-node" && nodeId) {
|
|
setHighlightedMapNode(nodeId);
|
|
setTimeout(() => setHighlightedMapNode(null), 5000);
|
|
}
|
|
}, 400);
|
|
addToolOutput({
|
|
tool: "navigate_to_section" as any,
|
|
toolCallId: toolCall.toolCallId,
|
|
output: `Navigated to "${section}" section${tabId ? `, activated tab "${tabId}"` : ""}${nodeId ? `, highlighted node "${nodeId}"` : ""}`,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
const isLoading = status === "submitted" || status === "streaming";
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
if (!isAiExpanded) setAiExpanded(true);
|
|
setTimeout(() => {
|
|
sendMessage({
|
|
text: `I'd like to schedule an engineering consultation${detail?.industry ? ` for my ${detail.industry} operation` : ""}${detail?.installation ? ` (reference: ${detail.installation})` : ""}. Please set that up.`,
|
|
});
|
|
}, 500);
|
|
};
|
|
window.addEventListener("flux:request-consultation", handler);
|
|
return () => window.removeEventListener("flux:request-consultation", handler);
|
|
}, [isAiExpanded, setAiExpanded, sendMessage]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
handleClose();
|
|
setTimeout(() => {
|
|
const section = document.getElementById("global");
|
|
if (section) section.scrollIntoView({ behavior: "smooth" });
|
|
if (detail?.nodeId) {
|
|
setSelectedMarkerId(detail.nodeId);
|
|
setHighlightedMapNode(detail.nodeId);
|
|
setTimeout(() => setHighlightedMapNode(null), 5000);
|
|
}
|
|
}, 400);
|
|
};
|
|
window.addEventListener("flux:navigate-to-case", handler);
|
|
return () => window.removeEventListener("flux:navigate-to-case", handler);
|
|
}, [setSelectedMarkerId, setHighlightedMapNode]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
if (!detail?.nodeId) return;
|
|
handleClose();
|
|
setTimeout(() => {
|
|
const section = document.getElementById("global");
|
|
if (section) section.scrollIntoView({ behavior: "smooth" });
|
|
setSelectedMarkerId(detail.nodeId);
|
|
}, 400);
|
|
};
|
|
window.addEventListener("flux:open-case-study-modal", handler);
|
|
return () => window.removeEventListener("flux:open-case-study-modal", handler);
|
|
}, [setSelectedMarkerId]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
if (!isAiExpanded) setAiExpanded(true);
|
|
setTimeout(() => {
|
|
if (detail?.prompt) sendMessage({ text: detail.prompt });
|
|
}, 500);
|
|
};
|
|
window.addEventListener("flux:trigger-ai", handler);
|
|
return () => window.removeEventListener("flux:trigger-ai", handler);
|
|
}, [isAiExpanded, setAiExpanded, sendMessage]);
|
|
|
|
const onSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!input.trim() || isLoading) return;
|
|
sendMessage({ text: input });
|
|
setInput("");
|
|
};
|
|
|
|
function renderToolPart(part: any, index: number) {
|
|
const key = `tool-${index}`;
|
|
const ToolLoading = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2.5 py-2"><div className="w-5 h-5 rounded-full bg-[#0066CC]/10 dark:bg-[#4DA6FF]/15 flex items-center justify-center"><Sparkles size={10} className="text-[#0066CC] dark:text-[#4DA6FF] animate-pulse" /></div><span className="text-[11px] text-[#86868B] dark:text-[#A1A1A6] animate-pulse">{label}</span></div>);
|
|
const DataToolWorking = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2 py-1"><Database size={10} className="text-[#0066CC]/40 dark:text-[#4DA6FF]/40 animate-pulse" /><span className="text-[10px] text-[#86868B]/60 dark:text-[#A1A1A6]/40 italic">{label}</span></div>);
|
|
const ToolError = ({ msg }: { msg?: string }) => (<div key={key} className="text-[11px] text-red-400 dark:text-red-300/80 self-start py-1">Analysis unavailable. {msg}</div>);
|
|
|
|
if (part.type === "tool-recommend_application") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Analyzing your needs..." />;
|
|
if (part.state === "output-available") return null;
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-search_installations") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Searching installation database..." />;
|
|
if (part.state === "output-available") return null;
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-get_application_knowledge") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Loading technical knowledge..." />;
|
|
if (part.state === "output-available") return null;
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-show_case_study") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Loading case study..." />;
|
|
if (part.state === "output-available") { const o = (part as any).output; if (!o?.found) return null; return <CaseStudyViewer key={key} data={o} />; }
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-show_equipment_specs") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Loading equipment specifications..." />;
|
|
if (part.state === "output-available") { const o = (part as any).output; if (!o?.found) return null; return <EquipmentConfigurator key={key} data={o} />; }
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-energy_savings_calculator") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label={`Calculating savings for ${(part as any).input?.industry || "your industry"}...`} />;
|
|
if (part.state === "output-available") return <EnergySavingsCalculator key={key} data={(part as any).output} />;
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-process_comparison_table") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Building technology comparison..." />;
|
|
if (part.state === "output-available") return <ProcessComparisonTable key={key} data={(part as any).output} />;
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-rf_technology_explainer") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Preparing RF visualization..." />;
|
|
if (part.state === "output-available") return <RFTechExplainer key={key} data={(part as any).output} />;
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-schedule_consultation") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <ToolLoading key={key} label="Preparing your consultation brief..." />;
|
|
if (part.state === "output-available") return <ConsultationScheduler key={key} data={(part as any).output} />;
|
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
|
}
|
|
if (part.type === "tool-navigate_to_section") {
|
|
if (part.state === "input-streaming" || part.state === "input-available") return <div key={key} className="self-start flex items-center gap-2 py-1"><div className="w-3 h-3 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF] animate-ping opacity-40" /><span className="text-[11px] text-[#86868B] dark:text-[#A1A1A6]">Navigating...</span></div>;
|
|
if (part.state === "output-available") return <motion.div key={key} initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="self-start text-[10px] text-[#86868B] dark:text-[#A1A1A6]/60 italic py-1">Navigated to section</motion.div>;
|
|
}
|
|
if (part.type === "tool-show_efficiency_calculator") {
|
|
if (part.state === "input-available" || part.state === "output-available") return <EfficiencyCard key={key} industry={(part as any).input?.industry || "Industrial"} estimatedSavingsPercent={(part as any).input?.estimatedSavingsPercent || 40} />;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ═══ Contextual Quick-Replies based on last assistant message ═══
|
|
function getContextualSuggestions(): string[] {
|
|
if (isLoading || messages.length === 0) return [];
|
|
const lastAssistant = [...messages].reverse().find(m => m.role === "assistant");
|
|
if (!lastAssistant?.parts) return [];
|
|
|
|
const toolTypes = new Set(
|
|
lastAssistant.parts
|
|
.filter((p: any) => p.type?.startsWith("tool-") && p.state === "output-available")
|
|
.map((p: any) => p.type)
|
|
);
|
|
|
|
// Priority order: suggest the next logical funnel step
|
|
if (toolTypes.has("tool-schedule_consultation")) return []; // End of funnel
|
|
if (toolTypes.has("tool-show_equipment_specs"))
|
|
return ["Schedule a consultation", "Compare with traditional methods"];
|
|
if (toolTypes.has("tool-show_case_study"))
|
|
return ["Show me equipment specs", "Calculate savings for my operation", "Schedule a consultation"];
|
|
if (toolTypes.has("tool-energy_savings_calculator"))
|
|
return ["Show me a real installation", "See equipment specs", "How does RF heating work?"];
|
|
if (toolTypes.has("tool-process_comparison_table"))
|
|
return ["Calculate savings for my operation", "Show me proven installations"];
|
|
if (toolTypes.has("tool-rf_technology_explainer"))
|
|
return ["What would I save in energy costs?", "Show me real installations"];
|
|
if (toolTypes.has("tool-recommend_application") || toolTypes.has("tool-get_application_knowledge"))
|
|
return ["Calculate energy savings", "Show me case studies", "Compare RF vs my current method"];
|
|
if (toolTypes.has("tool-navigate_to_section"))
|
|
return ["Tell me more about this", "How much energy can I save?"];
|
|
|
|
// Default: if assistant responded with text only (Stage 1 qualification)
|
|
return [];
|
|
}
|
|
|
|
const suggestions = getContextualSuggestions();
|
|
|
|
return (
|
|
<>
|
|
<AnimatePresence>
|
|
{isAiExpanded && (
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.5, ease: "easeInOut" }} onClick={handleClose}
|
|
className={`fixed inset-0 z-40 backdrop-blur-[4px] transition-colors duration-500 ${isWideMode ? "bg-black/30 dark:bg-black/60" : "bg-black/10 dark:bg-black/40"}`} />
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
|
|
<AnimatePresence mode="wait">
|
|
{!isAiExpanded ? (
|
|
<motion.button key="pill" layoutId="flux-ai-shell" onClick={toggleAi}
|
|
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
|
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
|
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
|
|
<div className="relative flex items-center justify-center">
|
|
<Sparkles size={18} className="text-[#0066CC] dark:text-[#4DA6FF] relative z-10" />
|
|
<div className="absolute inset-0 bg-[#0066CC] dark:bg-[#4DA6FF] rounded-full blur-md opacity-20 group-hover:opacity-40 transition-opacity" />
|
|
</div>
|
|
<span className="text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors">Ask FluxAI</span>
|
|
</motion.button>
|
|
) : (
|
|
<motion.div layout key="panel" layoutId="flux-ai-shell"
|
|
initial={{ opacity: 0, scale: 0.92, y: 30, filter: "blur(20px)" }} animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.92, y: 30, filter: "blur(20px)" }}
|
|
transition={{ type: "spring", stiffness: 300, damping: 28 }}
|
|
className={`pointer-events-auto flex flex-col overflow-hidden bg-white/70 dark:bg-[#1D1D1F]/60 backdrop-blur-[40px] backdrop-saturate-150 border border-white/60 dark:border-white/[0.08] shadow-[0_24px_80px_rgba(0,0,0,0.12)] dark:shadow-[inset_0_1px_1px_rgba(255,255,255,0.04),0_24px_80px_rgba(0,0,0,0.6)] transition-all duration-500 w-full max-w-[calc(100vw-1.5rem)] h-[70vh] max-h-[600px] rounded-[24px] ${isWideMode ? "md:w-[860px] lg:w-[1000px] md:h-[85vh] md:max-h-[900px] md:rounded-[32px]" : "md:w-[460px] md:h-[640px] md:max-h-[640px] md:rounded-[32px]"}`}>
|
|
<div className="absolute top-0 left-0 w-full h-24 bg-gradient-to-b from-white/[0.06] to-transparent pointer-events-none rounded-t-[24px] md:rounded-t-[32px] hidden dark:block" />
|
|
|
|
<div className="relative z-10 flex items-center justify-between px-5 py-4 border-b border-black/[0.04] dark:border-white/[0.06] bg-white/30 dark:bg-white/[0.02] transition-colors">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative flex items-center justify-center">
|
|
<Sparkles size={17} className="text-[#0066CC] dark:text-[#4DA6FF] relative z-10" />
|
|
{isLoading && (<motion.div animate={{ scale: [1, 1.6, 1], opacity: [0.4, 0, 0.4] }} transition={{ duration: 1.5, repeat: Infinity }} className="absolute inset-0 bg-[#0066CC] dark:bg-[#4DA6FF] rounded-full blur-sm" />)}
|
|
</div>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-[14px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] transition-colors">FluxAI</span>
|
|
<span className="hidden md:inline text-[11px] text-[#86868B] dark:text-[#A1A1A6] transition-colors">Engineering Advisor</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{messages.length > 0 && (
|
|
<button onClick={() => setIsWideMode(!isWideMode)} className="hidden md:flex p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/5 transition-colors group" title={isWideMode ? "Standard View" : "Immersive Focus Mode"}>
|
|
{isWideMode ? <Minimize2 size={15} className="text-[#86868B] dark:text-[#A1A1A6] group-hover:text-[#1D1D1F] dark:group-hover:text-white transition-colors" /> : <Maximize2 size={15} className="text-[#86868B] dark:text-[#A1A1A6] group-hover:text-[#1D1D1F] dark:group-hover:text-white transition-colors" />}
|
|
</button>
|
|
)}
|
|
<div className="w-px h-4 bg-black/10 dark:bg-white/10 mx-1 hidden md:block" />
|
|
<button onClick={handleClose} className="p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/5 transition-colors group"><Minus size={15} className="text-[#86868B] dark:text-[#A1A1A6] group-hover:text-[#1D1D1F] dark:group-hover:text-white transition-colors" /></button>
|
|
<button onClick={handleClose} className="p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/5 transition-colors group"><X size={15} className="text-[#86868B] dark:text-[#A1A1A6] group-hover:text-red-500 transition-colors" /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4 text-[13px] font-light scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
|
{messages.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center flex-1 gap-5 py-8">
|
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#0066CC]/10 to-[#00AAFF]/10 dark:from-[#4DA6FF]/15 dark:to-[#00AAFF]/10 flex items-center justify-center border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 transition-colors"><Sparkles size={24} className="text-[#0066CC] dark:text-[#4DA6FF]" /></div>
|
|
<div className="text-center">
|
|
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1.5 transition-colors">FluxAI Engineering Advisor</p>
|
|
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] max-w-[280px] leading-relaxed transition-colors">Ask about energy savings, compare RF vs traditional methods, or let me guide you through our technology.</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 justify-center mt-1">
|
|
{["How much energy can I save?", "RF vs Hot Air", "How does RF heating work?", "Show me proven installations"].map((q) => (
|
|
<button key={q} onClick={() => sendMessage({ text: q })} className="px-3.5 py-2 rounded-full text-[11px] font-medium bg-white/60 dark:bg-white/[0.06] border border-black/[0.06] dark:border-white/[0.08] text-[#1D1D1F] dark:text-[#A1A1A6] hover:bg-black/5 dark:hover:bg-white/10 hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-all duration-200">{q}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{messages.map((m) => (
|
|
<div key={m.id} className="flex flex-col gap-2 min-w-0">
|
|
{m.parts?.map((part, index) => {
|
|
if (part.type === "text" && part.text) {
|
|
if (m.role === "user") return <div key={index} className="max-w-[80%] md:max-w-[700px] p-3.5 rounded-2xl rounded-tr-md self-end bg-[#0066CC] dark:bg-[#0066CC]/90 text-white text-[13px] leading-relaxed shadow-md break-words">{part.text}</div>;
|
|
return <div key={index} className="max-w-[88%] md:max-w-[700px] p-4 rounded-2xl rounded-tl-md self-start bg-white/60 dark:bg-white/[0.05] border border-white/70 dark:border-white/[0.06] text-[#1D1D1F] dark:text-[#E5E5EA] shadow-sm dark:shadow-none transition-colors duration-300 break-words"><MarkdownRenderer content={part.text} /></div>;
|
|
}
|
|
if (part.type?.startsWith("tool-")) return renderToolPart(part, index);
|
|
if (part.type === "step-start" && index > 0) return <div key={index} className="border-t border-black/[0.04] dark:border-white/[0.04] my-1" />;
|
|
return null;
|
|
})}
|
|
|
|
</div>
|
|
))}
|
|
{isLoading && messages.length > 0 && (
|
|
<div className="self-start flex items-center gap-1.5 py-2 px-1">
|
|
{[0, 1, 2].map((i) => (<motion.div key={i} className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF]" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1, 0.8] }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }} />))}
|
|
</div>
|
|
)}
|
|
{suggestions.length > 0 && !isLoading && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, delay: 0.2 }}
|
|
className="flex flex-wrap gap-1.5 pt-1 pb-2"
|
|
>
|
|
{suggestions.map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => { sendMessage({ text: s }); }}
|
|
className="px-3 py-1.5 rounded-full text-[11px] font-medium bg-[#0066CC]/[0.06] dark:bg-[#4DA6FF]/[0.08] border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 text-[#0066CC] dark:text-[#4DA6FF] hover:bg-[#0066CC]/10 dark:hover:bg-[#4DA6FF]/15 hover:border-[#0066CC]/20 dark:hover:border-[#4DA6FF]/20 active:scale-95 transition-all duration-200"
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={onSubmit} className="relative z-10 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-black/[0.04] dark:border-white/[0.06] bg-white/40 dark:bg-white/[0.02] flex items-center gap-2.5 transition-colors duration-300">
|
|
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask about energy savings, RF technology..." className="flex-1 rounded-2xl border-none outline-none text-[13px] px-4 py-3 bg-black/[0.04] dark:bg-white/[0.06] text-[#1D1D1F] dark:text-[#F5F5F7] placeholder:text-[#86868B] dark:placeholder:text-[#A1A1A6]/60 focus:bg-black/[0.06] dark:focus:bg-white/[0.08] transition-colors duration-200" />
|
|
<button type="submit" disabled={!input.trim() || isLoading} className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 bg-[#0066CC] dark:bg-[#4DA6FF] text-white dark:text-[#0A0A0C] disabled:opacity-30 hover:shadow-lg hover:scale-105 active:scale-95 shadow-[0_4px_12px_rgba(0,102,204,0.3)] dark:shadow-[0_4px_12px_rgba(77,166,255,0.3)] transition-all duration-200"><ArrowRight size={16} strokeWidth={2.5} /></button>
|
|
</form>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</>
|
|
);
|
|
} |