- 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
340 lines
10 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|