nxtgauge-frontend-solid/src/components/AiChatWidget.tsx

614 lines
21 KiB
TypeScript
Raw Normal View History

import { createSignal, Show, For, onMount } from "solid-js";
import { MessageCircle, X, Send, Bot, User, Loader } from "lucide-solid";
const API = "/api";
interface ChatMessage {
role: "user" | "assistant";
content: string;
intent?: string;
2026-06-15 17:04:02 +05:30
status?: string;
suggestedAction?: string;
/** Semantic category: ticket_created | ticket_pending | kb_results | usage_info | navigation */
actionType?: string;
/** Original user text — stored so ticket confirm has a description to send */
userQuery?: string;
/** True once the user has clicked the confirm/action button on this message */
confirmed?: boolean;
}
interface ChatResponse {
reply: string;
conversation_id: string;
intent: string;
confidence: number;
remaining_credits?: number;
remaining_daily_actions?: number;
credits_charged?: number;
message?: string;
2026-06-15 17:04:02 +05:30
status?: string;
suggested_action?: string;
action_type?: string;
2026-06-15 17:04:02 +05:30
}
function statusLabel(status?: string): string | null {
switch (status) {
case "ticket_created":
return "Support ticket created";
case "kb_results":
return "Help articles found";
case "usage_summary":
return "AI usage summary";
default:
return null;
}
}
/** Returns the dashboard URL to navigate to for a given suggested_action key. */
function navUrl(action: string | undefined): string | null {
switch (action) {
case "open_support_ticket": return "/dashboard?nav=support";
case "open_help_search": return "/help-center";
case "open_account_settings": return "/dashboard?nav=settings";
case "open_billing":
case "show_usage_modal": return "/dashboard?nav=credits";
case "open_jd_generator": return "/dashboard?nav=job_descriptions";
case "open_cover_letter": return "/dashboard?nav=cover_letter";
case "open_resume_tailor": return "/dashboard?nav=resume";
case "open_lead_unlock": return "/dashboard?nav=leads";
case "open_auto_apply": return "/dashboard?nav=auto_apply";
case "open_form_extract": return "/dashboard?nav=form_extract";
default: return null;
}
}
/** Human-readable label for an action button. */
function actionLabel(action: string | undefined): string {
switch (action) {
case "open_support_ticket": return "View Tickets →";
case "open_help_search": return "Browse Help Center →";
case "open_account_settings": return "Account Settings →";
case "open_billing":
case "show_usage_modal": return "AI Credits & Billing →";
case "open_jd_generator": return "Job Description Generator →";
case "open_cover_letter": return "Cover Letter Generator →";
case "open_resume_tailor": return "Resume Tailor →";
case "open_lead_unlock": return "Lead Requests →";
case "open_auto_apply": return "Auto Apply →";
case "open_form_extract": return "Form Assistant →";
case "create_ticket": return "Create Support Ticket";
default: return "";
}
}
interface UsageStatus {
remaining_credits: number;
remaining_daily_actions: number;
daily_action_limit: number;
plan_code: string;
plan_name: string;
monthly_credits_total: number;
monthly_credits_used: number;
}
export function AiChatWidget() {
const [isOpen, setIsOpen] = createSignal(false);
const [messages, setMessages] = createSignal<ChatMessage[]>([
{
role: "assistant",
content:
"Hi! I'm Ask Ash, your Nxtgauge assistant. I can help you:\n• Search help articles & KB\n• Create support tickets\n• Explain your AI plan & usage\n• Answer questions about the platform",
},
]);
const [input, setInput] = createSignal("");
const [isLoading, setIsLoading] = createSignal(false);
const [conversationId, setConversationId] = createSignal("");
const [usage, setUsage] = createSignal<UsageStatus | null>(null);
onMount(() => {
const hasToken = typeof window !== "undefined" && !!sessionStorage.getItem("nxtgauge_access_token");
if (hasToken) fetchUsage();
});
const fetchUsage = async () => {
try {
const res = await fetch(`${API}/ai/usage/summary`, {
headers: {
Authorization: `Bearer ${sessionStorage.getItem("nxtgauge_access_token") || ""}`,
},
credentials: "include",
});
if (!res.ok) return;
const data = await res.json();
const plan = data.plan_details || data.plan || {};
setUsage({
remaining_credits: plan.remaining_credits ?? data.monthly_remaining ?? 0,
remaining_daily_actions: plan.remaining_daily_actions ?? data.daily_remaining ?? 0,
daily_action_limit: plan.daily_action_limit ?? data.daily_limit ?? 0,
plan_code: plan.plan_code ?? data.plan_code ?? "free",
plan_name: plan.plan_name ?? data.plan ?? "Free",
monthly_credits_total: plan.monthly_credits_total ?? data.monthly_limit ?? 0,
monthly_credits_used: plan.monthly_credits_used ?? data.monthly_used ?? 0,
});
} catch (err) {
console.error("Failed to fetch AI usage", err);
}
};
const toggleChat = () => setIsOpen((v) => !v);
const sendMessage = async () => {
const text = input().trim();
if (!text || isLoading()) return;
setIsLoading(true);
const userMessage: ChatMessage = { role: "user", content: text };
setMessages((prev) => [...prev, userMessage]);
setInput("");
try {
let res = await fetch(`${API}/ai/chat/ask`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text,
conversation_id: conversationId() || undefined,
}),
});
if (res.status === 401 || res.status === 403 || res.status === 404) {
res = await fetch(`${API}/ai/chat/message`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text,
conversation_id: conversationId() || undefined,
}),
});
}
if (!res.ok) throw new Error("AI request failed");
const data: ChatResponse = await res.json();
if (data.conversation_id && !conversationId()) {
setConversationId(data.conversation_id);
}
// Update usage state from response if available
if (typeof data.remaining_credits === "number") {
setUsage((prev) =>
prev
? {
...prev,
remaining_credits: data.remaining_credits!,
remaining_daily_actions: data.remaining_daily_actions ?? prev.remaining_daily_actions,
}
: null
);
}
const assistantMessage: ChatMessage = {
role: "assistant",
content: data.message || data.reply,
intent: data.intent,
2026-06-15 17:04:02 +05:30
status: data.status,
suggestedAction: data.suggested_action,
actionType: data.action_type,
userQuery: text,
confirmed: false,
};
setMessages((prev) => [...prev, assistantMessage]);
} catch (err) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content:
"I'm having trouble connecting right now. Please try again or contact support@nxtgauge.com.",
},
]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
/** Called when user clicks "Create Support Ticket" on a ticket_pending message. */
const confirmCreateTicket = async (msgIdx: number, userQuery: string) => {
// Mark the originating message as confirmed so the button disappears immediately
setMessages((prev) => prev.map((m, i) => (i === msgIdx ? { ...m, confirmed: true } : m)));
setIsLoading(true);
try {
const res = await fetch(`${API}/ai/chat/confirm`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${sessionStorage.getItem("nxtgauge_access_token") || ""}`,
},
credentials: "include",
body: JSON.stringify({
action: "create_ticket",
conversation_id: conversationId() || undefined,
fields: {
subject: userQuery.slice(0, 80),
description: userQuery,
category: "ai_assisted",
priority: "medium",
},
}),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: data.message || `Support ticket created. Our team will get back to you shortly.`,
status: "ticket_created",
suggestedAction: "open_support_ticket",
actionType: "ticket_created",
confirmed: false,
},
]);
} else {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: data.error || "Couldn't create the ticket right now. Please try again or email support@nxtgauge.com.",
},
]);
}
} catch {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: "Couldn't connect to create the ticket. Please email support@nxtgauge.com directly.",
},
]);
} finally {
setIsLoading(false);
}
};
return (
<>
{/* Floating button */}
<button
onClick={toggleChat}
style={{
position: "fixed",
bottom: "24px",
right: "24px",
width: "56px",
height: "56px",
"border-radius": "50%",
background: "#FF5E13",
border: "none",
cursor: "pointer",
display: "flex",
"align-items": "center",
"justify-content": "center",
"box-shadow": "0 4px 16px rgba(255, 90, 19, 0.35)",
"z-index": "9999",
transition: "transform 0.2s",
}}
title="AI Assistant"
aria-label={isOpen() ? "Close AI Assistant" : "Open AI Assistant"}
aria-expanded={isOpen()}
>
<Show when={isOpen()} fallback={<MessageCircle size={24} color="#fff" />}>
<X size={24} color="#fff" />
</Show>
</button>
{/* Chat window */}
<Show when={isOpen()}>
<div
role="dialog"
aria-label="AI Assistant chat"
aria-modal="true"
style={{
position: "fixed",
bottom: "96px",
right: "24px",
width: "380px",
height: "520px",
background: "#fff",
"border-radius": "16px",
"box-shadow": "0 8px 40px rgba(0,0,0,0.15)",
display: "flex",
"flex-direction": "column",
overflow: "hidden",
"z-index": "9998",
}}
>
{/* Header */}
<div
style={{
background: "linear-gradient(135deg, #FF5E13 0%, #E5470F 100%)",
padding: "16px 20px",
display: "flex",
"align-items": "center",
"justify-content": "space-between",
}}
>
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
<img
src="/ai-assistant-logo.png"
alt="AI Assistant"
style={{ width: "26px", height: "26px", "border-radius": "6px", "object-fit": "contain", "background": "transparent" }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<div>
<p style={{ margin: 0, color: "#fff", "font-weight": "700", "font-size": "15px" }}>
AI Assistant
</p>
<Show when={usage()}>
{(u) => (
<p style={{ margin: "2px 0 0", color: "rgba(255,255,255,0.85)", "font-size": "11px" }}>
{u().plan_name} · {u().remaining_credits} credits · {u().remaining_daily_actions}/{u().daily_action_limit} today
</p>
)}
</Show>
</div>
</div>
<button
onClick={toggleChat}
aria-label="Close chat"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px",
}}
>
<X size={20} color="#fff" />
</button>
</div>
{/* Quick actions */}
<div
style={{
padding: "10px 16px",
"border-bottom": "1px solid #E5E7EB",
display: "flex",
gap: "8px",
"flex-wrap": "wrap",
}}
>
2026-08-12 13:38:38 +02:00
<For each={[
{ label: "Support Ticket", text: "I need help with " },
{ label: "Search KB", text: "How do I " },
{ label: "AI Plan", text: "Explain my AI plan" },
{ label: "Check Balance", text: "Check my AI balance" },
2026-08-12 13:38:38 +02:00
]}>{(action) => (
<button
aria-label={`Quick action: ${action.label}`}
onClick={() => {
setInput(action.text);
}}
style={{
padding: "4px 10px",
"border-radius": "20px",
border: "1px solid #E5E7EB",
background: "#F9FAFB",
"font-size": "11px",
cursor: "pointer",
color: "#374151",
}}
>
{action.label}
</button>
2026-08-12 13:38:38 +02:00
)}</For>
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflow: "auto",
padding: "16px",
display: "flex",
"flex-direction": "column",
gap: "12px",
}}
>
<For each={messages()}>
{(msg, idx) => (
<>
<div
style={{
display: "flex",
"align-items": "flex-start",
gap: "8px",
"flex-direction": msg.role === "user" ? "row-reverse" : "row",
}}
>
<div
style={{
width: "28px",
height: "28px",
"border-radius": "50%",
background: msg.role === "user" ? "#FF5E13" : "#E5E7EB",
display: "flex",
"align-items": "center",
"justify-content": "center",
"flex-shrink": 0,
}}
>
<Show when={msg.role === "user"} fallback={<Bot size={14} color="#6B7280" />}>
<User size={14} color="#fff" />
</Show>
</div>
<div
style={{
"max-width": "75%",
padding: "10px 14px",
"border-radius": "14px",
background: msg.role === "user" ? "#FF5E13" : "#F3F4F6",
color: msg.role === "user" ? "#fff" : "#111827",
"font-size": "13px",
"line-height": "1.5",
}}
>
<p style={{ margin: 0, "white-space": "pre-wrap" }}>{msg.content}</p>
<Show when={msg.role === "assistant" && statusLabel(msg.status)}>
{(label) => (
<p
style={{
margin: "6px 0 0",
"font-size": "10px",
color: "#6B7280",
"font-weight": "600",
}}
>
{label()}
</p>
)}
</Show>
</div>
</div>
{/* Action buttons — only for unconfirmed assistant messages with a suggested action */}
<Show when={msg.role === "assistant" && msg.suggestedAction && !msg.confirmed}>
<div
style={{
"padding-left": "36px",
"margin-top": "-4px",
display: "flex",
gap: "6px",
"flex-wrap": "wrap",
}}
>
{/* Navigation button — links to the relevant dashboard section */}
<Show when={msg.suggestedAction !== "create_ticket" && navUrl(msg.suggestedAction)}>
{(url) => (
<a
href={url()}
style={{
display: "inline-flex",
"align-items": "center",
padding: "5px 12px",
background: "#FFF5F0",
border: "1px solid #FF5E13",
"border-radius": "20px",
color: "#FF5E13",
"font-size": "11px",
"font-weight": "600",
"text-decoration": "none",
cursor: "pointer",
transition: "background 0.15s",
}}
>
{actionLabel(msg.suggestedAction)}
</a>
)}
</Show>
{/* Confirm-create button — for ticket_pending messages */}
<Show when={msg.suggestedAction === "create_ticket" || msg.actionType === "ticket_pending"}>
<button
onClick={() => confirmCreateTicket(idx(), msg.userQuery || msg.content)}
2026-06-15 17:04:02 +05:30
style={{
display: "inline-flex",
"align-items": "center",
padding: "5px 12px",
background: "#FF5E13",
border: "none",
"border-radius": "20px",
color: "#fff",
"font-size": "11px",
2026-06-15 17:04:02 +05:30
"font-weight": "600",
cursor: "pointer",
2026-06-15 17:04:02 +05:30
}}
>
Create Support Ticket
</button>
</Show>
</div>
</Show>
</>
)}
</For>
<Show when={isLoading()}>
<div
style={{
display: "flex",
"align-items": "center",
gap: "8px",
color: "#9CA3AF",
"font-size": "13px",
}}
>
<Loader size={14} style={{ animation: "spin 1s linear infinite" }} />
Thinking...
</div>
</Show>
</div>
{/* Input */}
<div
style={{
padding: "12px 16px",
"border-top": "1px solid #E5E7EB",
display: "flex",
gap: "8px",
}}
>
<input
type="text"
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything..."
aria-label="Chat message input"
style={{
flex: 1,
height: "40px",
"border-radius": "20px",
border: "1px solid #E5E7EB",
padding: "0 16px",
"font-size": "13px",
outline: "none",
}}
/>
<button
onClick={sendMessage}
disabled={isLoading() || !input().trim()}
aria-label="Send message"
style={{
width: "40px",
height: "40px",
"border-radius": "50%",
background: isLoading() ? "#E5E7EB" : "#FF5E13",
border: "none",
cursor: isLoading() ? "default" : "pointer",
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<Send size={16} color="#fff" />
</button>
</div>
</div>
</Show>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</>
);
}