nxtgauge-frontend-solid/src/components/AiChatWidget.tsx
Tracewebstudio Dev 0d63bb304e Add comprehensive test infrastructure and AI guardrails
- Add Vitest unit tests for AiChatWidget component
- Add Playwright E2E tests: ai-chat-widget, api, security, guardrails
- Add AI guardrails tests validating no phone/email leaks from Ollama
- Add security tests: auth, rate limiting, input validation, token handling
- Add API tests for AI endpoints and authentication
- Fix playwright.config.ts reporter path conflict
- Update CompanyJobsPage with AI generate buttons (orange icon, loading state)
- Fix AiChatWidget accessibility (role=dialog, aria-label, aria-modal)
- Add app.css spin animation for AI loading spinner
- Add Gitea Actions workflow for nightly CI tests
- Add TESTING.md documentation
2026-05-01 02:54:25 +02:00

340 lines
10 KiB
TypeScript

import { createSignal, Show, For, onMount } from "solid-js";
import { MessageCircle, X, Send, Bot, User, Loader } from "lucide-solid";
const API = "/api/gateway";
interface ChatMessage {
role: "user" | "assistant";
content: string;
intent?: string;
}
interface ChatResponse {
message: string;
conversation_id: string;
intent: string;
confidence: number;
}
export function AiChatWidget() {
const [isOpen, setIsOpen] = createSignal(false);
const [messages, setMessages] = createSignal<ChatMessage[]>([
{
role: "assistant",
content:
"Hi! I'm your AI assistant. I can help you create support tickets, fill out forms, generate job descriptions, or write cover letters. What can I help you with?",
},
]);
const [input, setInput] = createSignal("");
const [isLoading, setIsLoading] = createSignal(false);
const [conversationId, setConversationId] = createSignal("");
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 {
const res = await fetch(`${API}/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);
}
const assistantMessage: ChatMessage = {
role: "assistant",
content: data.message,
intent: data.intent,
};
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();
}
};
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"
>
<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" }}>
<Bot size={22} color="#fff" />
<div>
<p style={{ margin: 0, color: "#fff", "font-weight": "700", "font-size": "15px" }}>
AI Assistant
</p>
<p style={{ margin: 0, color: "rgba(255,255,255,0.8)", "font-size": "11px" }}>
Powered by gemma3
</p>
</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",
}}
>
{["Create Ticket", "Job Description", "Cover Letter", "Fill Form"].map((label) => (
<button
aria-label={`Quick action: ${label}`}
onClick={() => {
setInput(`${label.toLowerCase()}: `);
}}
style={{
padding: "4px 10px",
"border-radius": "20px",
border: "1px solid #E5E7EB",
background: "#F9FAFB",
"font-size": "11px",
cursor: "pointer",
color: "#374151",
}}
>
{label}
</button>
))}
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflow: "auto",
padding: "16px",
display: "flex",
"flex-direction": "column",
gap: "12px",
}}
>
<For each={messages()}>
{(msg) => (
<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.intent && msg.role === "assistant"}>
<p
style={{
margin: "4px 0 0",
"font-size": "10px",
color: "#9CA3AF",
"font-style": "italic",
}}
>
Intent: {msg.intent}
</p>
</Show>
</div>
</div>
)}
</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>
</>
);
}