| 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} |
| </> |
| ); |
| } |
|
|