173 lines
6.0 KiB
TypeScript
173 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
|
|
interface Msg {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
}
|
|
|
|
export default function AskBBAI() {
|
|
const pathname = usePathname();
|
|
const [open, setOpen] = useState(false);
|
|
const [input, setInput] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [messages, setMessages] = useState<Msg[]>([
|
|
{
|
|
role: "assistant",
|
|
content:
|
|
"Hi — I'm AV Beat AI. Ask me anything about pro AV, live production, or display technology. I'll cite sources from the AV Beat archive.",
|
|
},
|
|
]);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
inputRef.current?.focus();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [open]);
|
|
|
|
if (pathname?.startsWith("/forum")) return null;
|
|
|
|
async function send() {
|
|
const q = input.trim();
|
|
if (!q || busy) return;
|
|
setErr(null);
|
|
setInput("");
|
|
const next = [...messages, { role: "user" as const, content: q }];
|
|
setMessages(next);
|
|
setBusy(true);
|
|
try {
|
|
const r = await fetch("/api/ask-bb-ai", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ messages: next }),
|
|
});
|
|
if (!r.ok) {
|
|
const body = await r.json().catch(() => ({}));
|
|
setErr(body.error || `HTTP ${r.status}`);
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
const data = await r.json();
|
|
setMessages([...next, { role: "assistant", content: data.reply || "(no response)" }]);
|
|
} catch (e: any) {
|
|
setErr(e.message || String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{!open && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="fixed bottom-16 right-6 z-40 bg-black text-white rounded-full pl-5 pr-5 py-3 shadow-2xl flex items-center gap-3 hover:bg-zinc-900 transition-colors"
|
|
aria-label="Ask AV Beat AI"
|
|
>
|
|
<span className="relative inline-block w-3 h-3">
|
|
<span className="absolute inset-0 rounded-full bg-[var(--color-text-info,#60a5fa)]" />
|
|
<span className="bb-ring" />
|
|
</span>
|
|
<span className="font-serif">Ask AV Beat AI</span>
|
|
</button>
|
|
)}
|
|
|
|
{open && (
|
|
<div
|
|
ref={drawerRef}
|
|
role="dialog"
|
|
aria-label="Ask AV Beat AI"
|
|
className="fixed right-0 top-0 bottom-0 z-50 w-full sm:w-[420px] bg-[#0a0a0a] border-l border-[#DCE6F2] flex flex-col"
|
|
>
|
|
<header className="flex items-center justify-between px-5 py-4 border-b border-[#DCE6F2]">
|
|
<div className="flex items-center gap-2">
|
|
<span className="relative inline-block w-3 h-3">
|
|
<span className="absolute inset-0 rounded-full bg-[var(--color-text-info,#60a5fa)]" />
|
|
<span className="bb-ring" />
|
|
</span>
|
|
<h2 className="font-serif text-lg font-semibold text-white">AV Beat AI</h2>
|
|
</div>
|
|
<button
|
|
onClick={() => setOpen(false)}
|
|
aria-label="Close"
|
|
className="text-zinc-400 hover:text-white"
|
|
>
|
|
✕
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
{messages.map((m, i) => (
|
|
<div key={i} className={m.role === "user" ? "text-right" : ""}>
|
|
<div
|
|
className={
|
|
"inline-block max-w-[85%] rounded px-3 py-2 text-sm whitespace-pre-wrap " +
|
|
(m.role === "user"
|
|
? "bg-[var(--color-text-info,#60a5fa)] text-black"
|
|
: "bg-[#111] border border-[#DCE6F2] text-[#e5e7eb]")
|
|
}
|
|
dangerouslySetInnerHTML={{ __html: m.role === "assistant" ? linkifyMarkdown(m.content) : escapeHtml(m.content) }}
|
|
/>
|
|
</div>
|
|
))}
|
|
{busy && (
|
|
<div className="text-xs text-[#6b7280] inline-flex items-center gap-2">
|
|
<span className="bb-pulse-dot" /> Thinking…
|
|
</div>
|
|
)}
|
|
{err && <div className="text-sm text-red-400">{err}</div>}
|
|
</div>
|
|
|
|
<div className="p-4 border-t border-[#DCE6F2]">
|
|
<div className="flex gap-2">
|
|
<input
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
|
placeholder="Ask about an event, a vendor, a workflow…"
|
|
disabled={busy}
|
|
className="flex-1 bg-[#111] border border-[#DCE6F2] rounded px-3 py-2 text-sm text-[#e5e7eb] focus:border-[var(--color-text-info,#60a5fa)] focus:outline-none"
|
|
/>
|
|
<button
|
|
onClick={send}
|
|
disabled={busy || !input.trim()}
|
|
className="bg-[var(--color-text-info,#60a5fa)] text-black font-semibold px-4 py-2 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
Send
|
|
</button>
|
|
</div>
|
|
<p className="text-[10px] font-mono text-[#6b7280] mt-2">
|
|
30 messages/hour · cites AV Beat archive
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
function linkifyMarkdown(s: string): string {
|
|
let out = escapeHtml(s);
|
|
out = out.replace(
|
|
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
'<a href="$2" class="text-[#1D4ED8] hover:underline">$1</a>'
|
|
);
|
|
out = out.replace(/\n/g, "<br/>");
|
|
return out;
|
|
}
|