Masters-four-Tab-OpenAI / frontend /src /components /FloatingRouterHelper.tsx
Pete Dunn
Remove duplicate assistant security gates
ba4421e
Raw
History Blame Contribute Delete
20.8 kB
import React, { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ButtonPrimary, ButtonSoft } from "./ui";
import { apiFetch } from "../api/client";
import { parseResponseBody, resolveApiErrorInfo } from "../utils/responseShell";
import { useAppCommandListener } from "../utils/appCommands";
import { confirmDestructiveAction } from "../utils/confirmAction";
type HelperMessage = {
id: string;
role: "assistant" | "user";
text: string;
tone?: "slate" | "green" | "amber" | "red" | "blue";
};
type UtilityTab = "assist" | "support";
const ROUTER_HELPER_CONTEXT_SESSION_KEY = "masters_toolkit_router_helper_context_v1";
const HELPER_INTRO = "I can answer router selection and FAQ questions from internal sources.";
const HELPER_PROMPTS: string[] = [
"Recommend 2 routers for a small branch using primary 5G.",
"Compare selected routers by device details.",
"What is network slicing?",
];
const HELPER_DETAILS_THRESHOLD = 540;
const TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
const TABLE_DELIM_RE = /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/;
const SUPPORT_EMAIL = "support@masterstelecom.com";
const SUPPORT_PHONE_DISPLAY = "561-531-0462";
const SUPPORT_PHONE_TEL = "+15615310462";
const SUPPORT_PHONE_EXTENSION = "Option 6";
const SUPPORT_SLACK_URL = "https://verizon.enterprise.slack.com/archives/C035HSFT7MZ";
function safeUrlTransform(url: string) {
try {
const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
const parsed = new URL(url, base);
if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.toString();
return "";
} catch {
return "";
}
}
function HelperMarkdownTable(props: any) {
const [readerOpen, setReaderOpen] = useState(false);
const inlineTableRef = useRef<HTMLTableElement | null>(null);
const modalTableRef = useRef<HTMLTableElement | null>(null);
useEffect(() => {
if (!readerOpen) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setReaderOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [readerOpen]);
return (
<>
<div className="my-3 overflow-hidden rounded-[16px] border border-slate-200 bg-white/95 shadow-sm">
<div className="space-y-2 border-b border-slate-200 px-3 py-3">
<div className="mt-section-subtle">Comparison ready</div>
<button
type="button"
className="mt-button-primary w-full rounded-xl border-2 px-4 py-2.5 text-base"
onClick={() => setReaderOpen(true)}
>
Click here for comparison table
</button>
</div>
<div className="max-h-[20rem] overflow-auto bg-white [&_tbody_tr:nth-child(even)]:bg-slate-50/55">
<table
ref={inlineTableRef}
className="min-w-[64rem] w-max text-sm text-slate-800 [border-collapse:separate] [border-spacing:0]"
{...props}
/>
</div>
</div>
{readerOpen ? (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/50 p-2 backdrop-blur-[1px]"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setReaderOpen(false);
}}
>
<div className="flex h-[94vh] w-full max-w-[96vw] flex-col overflow-hidden rounded-[20px] border border-slate-200 bg-white shadow-2xl">
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-4 py-3">
<div>
<div className="text-sm font-semibold text-slate-900">Comparison table reader</div>
<div className="text-xs text-slate-600">Full-table view with sticky headers for easier model comparison.</div>
</div>
<button
type="button"
className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50"
onClick={() => setReaderOpen(false)}
>
Close
</button>
</div>
<div className="flex-1 overflow-auto bg-white px-3 py-3 [scrollbar-gutter:stable]">
<table
ref={modalTableRef}
className="min-w-[92rem] w-max text-sm text-slate-900 [border-collapse:separate] [border-spacing:0] [&_tbody_tr:nth-child(even)]:bg-slate-50/50"
{...props}
/>
</div>
</div>
</div>
) : null}
</>
);
}
function findMarkdownTableRange(lines: string[]): [number, number] | null {
for (let i = 0; i < lines.length - 1; i++) {
if (!TABLE_ROW_RE.test(lines[i]) || !TABLE_DELIM_RE.test(lines[i + 1])) continue;
let end = i + 2;
while (end < lines.length && TABLE_ROW_RE.test(lines[end])) end += 1;
return [i, end];
}
return null;
}
function hasMarkdownTable(markdown: string): boolean {
const lines = String(markdown || "").split(/\r?\n/);
return findMarkdownTableRange(lines) !== null;
}
function simplifyComparisonTableAnswer(markdown: string): string {
const raw = String(markdown || "").trim();
if (!raw) return raw;
const lines = raw.split(/\r?\n/);
const tableRange = findMarkdownTableRange(lines);
if (!tableRange) return raw;
const [start, end] = tableRange;
const tableOnly = lines.slice(start, end).join("\n").trim();
if (!tableOnly) return raw;
return `Click here for comparison table.\n\n${tableOnly}`;
}
function buildMessageWithContext(message: string): string {
const trimmed = String(message || "").trim();
if (!trimmed) return "";
try {
const context = String(window.sessionStorage.getItem(ROUTER_HELPER_CONTEXT_SESSION_KEY) || "").trim();
if (!context) return trimmed;
return `${trimmed}\n\nContext from Rapid Router form:\n${context}`;
} catch {
return trimmed;
}
}
function helperPreviewText(markdown: string): string {
const flattened = String(markdown || "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/\|[^|\n]*(\|[^|\n]*)+\|?/g, " ")
.replace(/[#>*_`~]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!flattened) return "Answer generated.";
if (flattened.length <= 180) return flattened;
return `${flattened.slice(0, 177).trimEnd()}...`;
}
function UtilityTabButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={[
"mt-focus rounded-full border px-3 py-2 text-sm font-semibold transition",
active
? "text-white shadow-sm"
: "border-slate-200 bg-white text-slate-700 hover:bg-slate-50",
].join(" ")}
style={active ? { borderColor: "var(--mt-primary)", backgroundColor: "var(--mt-primary)" } : undefined}
>
{label}
</button>
);
}
export default function FloatingRouterHelper() {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<UtilityTab>("assist");
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [state, setState] = useState<Record<string, any>>({});
const [messages, setMessages] = useState<HelperMessage[]>([
{ id: "global-router-helper-intro", role: "assistant", text: HELPER_INTRO, tone: "blue" },
]);
const helperMdComponents = useMemo(
() => ({
h1: (p: any) => <h1 className="mb-1 mt-2 text-base font-semibold text-slate-900" {...p} />,
h2: (p: any) => <h2 className="mb-1 mt-2 text-base font-semibold text-slate-900" {...p} />,
h3: (p: any) => <h3 className="mb-1 mt-2 text-sm font-semibold text-slate-900" {...p} />,
p: (p: any) => <p className="my-1.5 leading-relaxed text-[15px] text-slate-800" {...p} />,
ul: (p: any) => <ul className="my-1.5 list-disc space-y-1 pl-5 text-[15px] text-slate-800" {...p} />,
ol: (p: any) => <ol className="my-1.5 list-decimal space-y-1 pl-5 text-[15px] text-slate-800" {...p} />,
li: (p: any) => <li className="my-1" {...p} />,
table: (p: any) => <HelperMarkdownTable {...p} />,
th: (p: any) => (
<th
className="sticky top-0 z-[2] border-b border-slate-200 bg-slate-50 p-3 text-left text-[13px] font-semibold leading-snug text-slate-700 first:sticky first:left-0 first:z-[3] first:border-r first:border-slate-200"
{...p}
/>
),
td: (p: any) => (
<td
className="max-w-[28rem] border-b border-slate-100 p-3 align-top text-[13px] leading-relaxed text-slate-800 first:sticky first:left-0 first:z-[1] first:border-r first:border-slate-100 first:bg-white whitespace-pre-wrap break-words"
{...p}
/>
),
a: (p: any) => <a {...p} target="_blank" rel="noreferrer" className="underline" />,
code: (p: any) => <code className="rounded bg-slate-100 px-1 py-0.5 text-[12px] text-slate-800" {...p} />,
}),
[]
);
const resetChat = () => {
if (!confirmDestructiveAction("Reset the assist conversation?")) return false;
setBusy(false);
setInput("");
setState({});
setMessages([{ id: `global-router-helper-intro-${Date.now()}`, role: "assistant", text: HELPER_INTRO, tone: "blue" }]);
return true;
};
const openLauncher = (tab: UtilityTab) => {
setActiveTab(tab);
setOpen(true);
};
useAppCommandListener((command) => {
if (command === "router_helper:open") {
openLauncher("assist");
return;
}
if (command === "support:open") {
openLauncher("support");
return;
}
if (command === "router_helper:close" || command === "support:close") {
setOpen(false);
}
});
const sendMessage = async (overrideMessage?: string) => {
if (busy) return;
const raw = typeof overrideMessage === "string" ? overrideMessage : input;
const userMessage = String(raw || "").trim();
if (!userMessage) return;
setMessages((prev) => [...prev, { id: `global-router-helper-user-${Date.now()}`, role: "user", text: userMessage }]);
if (typeof overrideMessage !== "string") setInput("");
setBusy(true);
try {
const enriched = buildMessageWithContext(userMessage);
const res = await apiFetch("/api/knowledgebase/message", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: enriched,
state,
mode: "router_docs",
audience: "auto",
show_citations: false,
}),
});
const data = (await parseResponseBody(res)) as any;
if (!res.ok) {
const info = resolveApiErrorInfo(data, `Assist request failed (${res.status}).`);
const errorText = info.shell?.result || info.text || "Assist request failed.";
setMessages((prev) => [
...prev,
{ id: `global-router-helper-assistant-error-${Date.now()}`, role: "assistant", text: errorText, tone: "red" },
]);
return;
}
const assistantRaw = String(data?.assistant || "").trim() || "I could not generate an answer for that request. Please rephrase.";
const assistantText = simplifyComparisonTableAnswer(assistantRaw);
const nextState = data?.state && typeof data.state === "object" ? (data.state as Record<string, any>) : {};
setState(nextState);
setMessages((prev) => [
...prev,
{ id: `global-router-helper-assistant-${Date.now()}`, role: "assistant", text: assistantText, tone: "blue" },
]);
} catch (e: any) {
setMessages((prev) => [
...prev,
{
id: `global-router-helper-assistant-catch-${Date.now()}`,
role: "assistant",
text: e?.message || "Assist request failed.",
tone: "red",
},
]);
} finally {
setBusy(false);
}
};
return (
<>
{!open ? (
<button
type="button"
className="mt-button-primary fixed bottom-5 right-5 z-[82] rounded-full border-2 px-4 py-2 text-sm shadow-lg"
onClick={() => setOpen(true)}
aria-label="Open help and assist launcher"
>
Help
</button>
) : null}
{open ? (
<div className="fixed inset-x-2 bottom-2 z-[82] flex max-h-[86vh] flex-col overflow-hidden rounded-[20px] border border-slate-200 bg-white shadow-2xl sm:inset-x-auto sm:bottom-5 sm:right-5 sm:w-[min(92vw,560px)]">
<div className="flex items-center justify-between gap-2 border-b border-slate-200 px-4 py-3">
<div>
<div className="text-lg font-semibold text-slate-900">Help + assist</div>
<div className="text-xs text-slate-600">
{activeTab === "assist"
? busy
? "Assist is working on your question."
: "Ask router questions here, or switch to Support for a person."
: "Support is available by phone, email, or Slack. Switch back to Assist for router questions."}
</div>
</div>
<button
type="button"
className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50"
onClick={() => setOpen(false)}
>
Close
</button>
</div>
<div className="border-b border-slate-200 px-4 py-3">
<div role="tablist" aria-label="Help launcher sections" className="flex flex-wrap gap-2">
<UtilityTabButton label="Assist" active={activeTab === "assist"} onClick={() => setActiveTab("assist")} />
<UtilityTabButton label="Support" active={activeTab === "support"} onClick={() => setActiveTab("support")} />
</div>
</div>
{activeTab === "assist" ? (
<div role="tabpanel" aria-label="Assist tab" className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div role="log" aria-live="polite" aria-relevant="additions text" className="max-h-[42vh] min-h-0 space-y-3 overflow-y-auto px-4 py-3 sm:max-h-[46vh]">
{messages.map((msg) => {
const isAssistant = msg.role === "assistant";
const tone = msg.tone || "slate";
const isTableAnswer = isAssistant && hasMarkdownTable(msg.text);
const hasLongAnswer = isAssistant && !isTableAnswer && msg.text.length > HELPER_DETAILS_THRESHOLD;
const bubbleClass =
tone === "red"
? "mt-panel-danger"
: tone === "amber"
? "mt-panel-warning"
: tone === "blue"
? "mt-panel-primary"
: "border-slate-200 bg-slate-50 text-slate-900";
const bubbleWidthClass = isAssistant ? "w-full max-w-full" : "max-w-[95%]";
return (
<div key={msg.id} className={["flex", isAssistant ? "justify-start" : "justify-end"].join(" ")}>
<div className={[bubbleWidthClass, "rounded-[16px] border px-3 py-2.5 text-sm leading-relaxed", bubbleClass].join(" ")}>
{isAssistant ? (
isTableAnswer ? (
<ReactMarkdown urlTransform={safeUrlTransform} remarkPlugins={[remarkGfm]} components={helperMdComponents}>
{msg.text}
</ReactMarkdown>
) : hasLongAnswer ? (
<div className="space-y-2">
<div className="mt-section-subtle text-slate-700">Answer</div>
<div className="text-sm text-slate-800">{helperPreviewText(msg.text)}</div>
<details className="rounded-[12px] border border-slate-200 bg-white/80 px-3 py-2">
<summary className="cursor-pointer text-xs font-semibold text-slate-700">View details</summary>
<div className="mt-2">
<ReactMarkdown urlTransform={safeUrlTransform} remarkPlugins={[remarkGfm]} components={helperMdComponents}>
{msg.text}
</ReactMarkdown>
</div>
</details>
</div>
) : (
<ReactMarkdown urlTransform={safeUrlTransform} remarkPlugins={[remarkGfm]} components={helperMdComponents}>
{msg.text}
</ReactMarkdown>
)
) : (
<div className="whitespace-pre-wrap">{msg.text}</div>
)}
</div>
</div>
);
})}
</div>
<div className="space-y-3 border-t border-slate-200 px-4 py-3">
<div className="flex flex-wrap gap-2">
{HELPER_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
onClick={() => setInput(prompt)}
disabled={busy}
>
{prompt}
</button>
))}
</div>
<textarea
className="min-h-[96px] w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"
placeholder="Ask for router picks, model comparisons, or FAQ guidance."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void sendMessage();
}
}}
disabled={busy}
/>
<div className="flex items-center justify-between gap-2">
<ButtonSoft onClick={resetChat} disabled={busy}>
Reset assist
</ButtonSoft>
<ButtonPrimary
onClick={() => void sendMessage()}
disabled={busy || !input.trim()}
>
{busy ? "Thinking..." : "Ask assist"}
</ButtonPrimary>
</div>
</div>
</div>
) : (
<div role="tabpanel" aria-label="Support tab" className="space-y-3 px-4 py-4">
<div className="rounded-[16px] border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
For a fast answer, use Slack first. If you need human follow-up, email or phone is available here.
</div>
<a
href={SUPPORT_SLACK_URL}
target="_blank"
rel="noreferrer"
className="mt-button-success block w-full rounded-xl border-2 px-4 py-3 text-center text-sm"
>
Open Slack support
</a>
<a
href={`mailto:${SUPPORT_EMAIL}?subject=Masters%20Toolkit%20Support%20Request`}
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-center text-sm font-semibold text-slate-800 hover:bg-slate-50"
>
Email {SUPPORT_EMAIL}
</a>
<a
href={`tel:${SUPPORT_PHONE_TEL}`}
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-center text-sm font-semibold text-slate-800 hover:bg-slate-50"
>
Call {SUPPORT_PHONE_DISPLAY} ({SUPPORT_PHONE_EXTENSION})
</a>
<div className="rounded-[12px] border border-slate-200 bg-white px-4 py-3 text-xs text-slate-600">
Need router questions answered first? Switch to <span className="font-semibold">Assist</span> for internal-source guidance and model comparison help.
</div>
</div>
)}
</div>
) : null}
</>
);
}