/* ========================================================================== useStreamedSummary.ts -------------------------------------------------------------------------- v2's stream. When API_ENDPOINT is set, this calls the real deployed API (../referral-signals-api); when it's empty, it falls back to a local mock that types out two sentences on a timer, standing in for the response. A Turnstile token is fetched right before each real request, via SB.getTurnstileToken by default or options.getTurnstileToken when the caller wants a different widget (test.html gives the Sandbox its own, see there); on a page with no widget, or if the API rejects the request for any reason (rate limit, guardrail, Turnstile), v2 falls back to v1's own text with a note instead of an error, see V2Slot in test.html. Behavior, per phases.md Phase 3: - Debounced ~500ms after the last input change, not on every drag tick. - options.auto === false suppresses that entirely, leaving the caller to fire each request through regenerate(). Every request is a billed model call, so a page with more than one banner, or with sliders feeding the inputs, should drive them explicitly rather than let a page load or a drag fan out into many; see test.html. - `stale` goes true the instant inputs change, so the caller can dim the v2 banner while a regenerate is pending or in flight. - A request started for one set of inputs is abandoned the moment newer inputs arrive, via the requestId guard below, backed by a real AbortController for the fetch path and just an ignored stale callback for the mock path (there's no real request there to abort). React is a UMD global. This file has no JSX, so unlike the .tsx files it stays a plain script to tsc and can reference `React` directly, no local alias needed (see StatsRow.tsx for why the .tsx files need one). ========================================================================== */ (function (SB: any) { "use strict"; const useState = React.useState; const useRef = React.useRef; const useEffect = React.useEffect; const DEBOUNCE_MS = 500; const THINK_MS = 400; // loading, before the first word (mock path only) const WORD_MS = 55; // streaming, per word // Set once referral-signals-api has a Turnstile site and this is ready // to take real traffic; empty keeps every page on the local mock. See // that project's README for what's still manual. const API_ENDPOINT = "https://referral-signals-api.vercel.app/api/summary"; function toIso(value: DateLike): string | null { if (value === null || value === undefined || value === "") return null; if (value instanceof Date) return value.toISOString(); if (typeof value === "number") return new Date(value).toISOString(); return value; } const FALLBACK_MESSAGES: Record = { rate_limit: "Too many requests right now.", turnstile: "Couldn't verify the request.", bad_request: "Something was wrong with the request.", server_error: "The summary service hit an error.", guardrail: "Couldn't generate a summary.", }; /* ---- mock copy --------------------------------------------------------- Two sentences: an activity read (same signals as v1, independently phrased, no restated numbers) and a matter summary. Deliberately not selectBanner's COPY table: v2 is meant to read as its own system, with v1 as the guardrail (Phase 4), not the voice. ------------------------------------------------------------------------ */ function describeActivity(posting: ReferralStats, now: number): string { const h = SB.helpers; const T = SB.THRESHOLDS; const responses = posting.responseCount || 0; const isFresh = h.isWithinHours(posting.postedAt, T.freshHours, now); const ownerActive = h.isWithinHours(posting.lastOwnerMessageAt, T.ownerActiveHours, now); if (posting.ownerHasResponded && ownerActive) { return "The attorney is actively reading responses right now."; } if (isFresh && responses === 0) { return "This just went live, so an early reply has the best shot at standing out."; } if (isFresh && responses > 0 && responses <= T.competitiveMax && !posting.ownerHasResponded) { return "A couple of replies are already in, but there is still room to make the shortlist."; } if (isFresh && responses >= T.trendingMin && !posting.ownerHasResponded) { return "This is drawing noticeably more interest than most, so a fast reply matters here."; } if (h.isWithinDays(posting.postedAt, T.dormantDays, now)) { return "Interest has cooled since it first posted, which usually means a fresh reply gets noticed again."; } return "This has been open for a while without much recent activity."; } function article(word: string): string { return /^[aeiou]/i.test(word) ? "an" : "a"; } function describeMatter(matter: Matter): string { const practiceArea = matter.practiceArea.toLowerCase(); return ( "It is " + article(practiceArea) + " " + practiceArea + " matter out of " + matter.state + ": " + matter.description + " Fee split runs " + matter.feeSplit + "." ); } /* ---- hook --------------------------------------------------------------- */ interface StreamResult { state: BannerState; // activityText fills in first (while matterText is still undefined, // the activity line itself may still be mid-type); matterText fills in // second, once activity is complete. A reader scans "what's happening" // separately from "what this is," per the two-line layout in // SummaryBanner.tsx (bold activity lead, regular matter line), not a // single run-on paragraph; see revealWords for how both grow in place // rather than one combined string getting re-split at the end. activityText?: string; matterText?: string; error?: string; // The category v2 converged on, once state === "done". Computed from // the same selectBanner v1 uses (StatusBanner.tsx), not inferred from // which describeActivity branch produced the text: this is what the // real API's guardrail actually checks the model's own stated category // against, and a mismatch there never reaches the client as "done" (it // becomes a guardrail failure instead; see forceError below and // referral-signals-api's lib/guardrails.ts). So in a working system // this always equals v1's own tag, by construction, shown here as a // transparency device, not a disagreement detector. category?: BannerKey | "none"; stale: boolean; regenerate: () => void; } interface StreamOptions { forceError?: boolean; // Which Turnstile widget's getter to use. Defaults to // SB.getTurnstileToken. getTurnstileToken?: (isStale?: () => boolean) => Promise; // Default true: a depsKey change debounces and then fires on its own. // // Pass false to make every request explicit, i.e. nothing fires on // mount or when depsKey changes, and the caller drives each one through // regenerate(). `stale` still flips on a depsKey change, so the UI can // show that what's on screen no longer matches the inputs and offer a // regenerate. // // This exists because each request is a real, billed model call. // Auto-firing is fine for a single banner reacting to real data, but a // page with several banners, or with sliders wired to the inputs, turns // a page load or a drag into a fan-out of calls. test.html passes false // for exactly that reason. auto?: boolean; } function useStreamedSummary( posting: ReferralStats, viewer: Viewer, matter: Matter, now: number, depsKey: string, options?: StreamOptions ): StreamResult { const [state, setState] = useState("loading"); const [activityText, setActivityText] = useState(undefined); const [matterText, setMatterText] = useState(undefined); const [error, setError] = useState(undefined); const [category, setCategory] = useState(undefined); const [stale, setStale] = useState(false); // Identifies the current request; any timer or fetch callback whose id // no longer matches belongs to an abandoned run and writes nothing. const requestId = useRef(0); const timers = useRef([]); const abortRef = useRef(null); const forceError = Boolean(options && options.forceError); const tokenGetter = (options && options.getTurnstileToken) || SB.getTurnstileToken; const auto = !(options && options.auto === false); function clearTimers() { timers.current.forEach(function (t) { window.clearTimeout(t); }); timers.current = []; if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } } // Shared by both paths: types activity out word by word, then matter, // same timing the mock always used, so the real API's buffered // (non-streamed) response still gets the typed-out look on screen (see // the file header). Both lines are already known before this runs (the // real API returns them split, not as one blob to re-split later), so // this builds the actual final two-line shape from the first word // instead of typing one plain combined line and snapping into the // bold/plain layout only once done; SummaryBanner.tsx renders // activityText/matterText the same way whether state is "streaming" or // "done", just with the matter line still short or absent mid-type. function revealWords( activity: string, matter: string, myId: number, resolvedCategory: BannerKey | "none" ) { const activityWords = activity.split(" "); const matterWords = matter.split(" "); setState("streaming"); activityWords.forEach(function (_word, i) { const wordTimer = window.setTimeout(function () { if (requestId.current !== myId) return; setActivityText(activityWords.slice(0, i + 1).join(" ")); }, (i + 1) * WORD_MS); timers.current.push(wordTimer); }); const matterStartDelay = activityWords.length * WORD_MS; matterWords.forEach(function (_word, i) { const wordTimer = window.setTimeout( function () { if (requestId.current !== myId) return; setMatterText(matterWords.slice(0, i + 1).join(" ")); if (i === matterWords.length - 1) { setCategory(resolvedCategory); setState("done"); } }, matterStartDelay + (i + 1) * WORD_MS ); timers.current.push(wordTimer); }); } function start(myId: number) { setState("loading"); setActivityText(undefined); setMatterText(undefined); setError(undefined); setCategory(undefined); setStale(false); if (forceError) { const thinkTimer = window.setTimeout(function () { if (requestId.current !== myId) return; setState("error"); setError("Couldn't generate a summary."); }, THINK_MS); timers.current.push(thinkTimer); return; } if (API_ENDPOINT) { const controller = new AbortController(); abortRef.current = controller; // Falls back to a placeholder on a page with no widget wired up // (none exist yet besides test.html); the API fails closed on // that, same as an invalid real token would. The isStale callback // lets a shared, serialized widget (test.html's) skip this call // instantly if it's superseded before its turn comes up, instead // of still burning a full execute() round trip on an abandoned // request. const tokenPromise: Promise = tokenGetter ? tokenGetter(function () { return requestId.current !== myId; }) : Promise.resolve("no-widget-on-this-page"); tokenPromise .then(function (token) { if (requestId.current !== myId) return Promise.reject({ name: "AbortError" }); return fetch(API_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stats: { state: posting.state, isDirect: posting.isDirect, postedAt: toIso(posting.postedAt), viewCount: posting.viewCount, responseCount: posting.responseCount, lastResponseAt: toIso(posting.lastResponseAt), ownerHasResponded: posting.ownerHasResponded, lastOwnerMessageAt: toIso(posting.lastOwnerMessageAt), }, viewer: viewer, matter: matter, turnstileToken: token || "turnstile-failed", }), signal: controller.signal, }); }) .then(function (res) { return res.json(); }) .then(function (data) { if (requestId.current !== myId) return; if (!data.ok) { setState("error"); setError(FALLBACK_MESSAGES[data.reason] || "Couldn't generate a summary."); return; } revealWords(data.activityText, data.matterText, myId, data.category); }) .catch(function (err) { if (requestId.current !== myId) return; // An intentional abort (superseded by newer inputs), not a // real failure; the effect that aborted us already started a // fresh request under a new id. if (err && err.name === "AbortError") return; setState("error"); setError("Couldn't reach the summary service."); }); return; } // No API_ENDPOINT configured: the local mock. const thinkTimer = window.setTimeout(function () { if (requestId.current !== myId) return; const activity = describeActivity(posting, now); const matterLine = describeMatter(matter); // Same source of truth v1 uses, and what the real API's guardrail // checks the model against; see the StreamResult.category comment. const chosen = SB.selectBanner(posting, viewer, now); const resolvedCategory: BannerKey | "none" = chosen ? chosen.key : "none"; revealWords(activity, matterLine, myId, resolvedCategory); }, THINK_MS); timers.current.push(thinkTimer); } function regenerate() { requestId.current += 1; clearTimers(); start(requestId.current); } useEffect( function () { requestId.current += 1; const myId = requestId.current; setStale(true); clearTimers(); // Manual mode: bumping requestId above already abandoned anything in // flight and marked the output stale. Firing is the caller's call. if (!auto) return clearTimers; const debounceTimer = window.setTimeout(function () { if (requestId.current !== myId) return; start(myId); }, DEBOUNCE_MS); timers.current.push(debounceTimer); return clearTimers; }, // depsKey stands in for posting/matter/forceError: those are fresh // object literals every render, so comparing them directly would // restart the stream on every keystroke elsewhere on the page. [depsKey] ); return { state: state, activityText: activityText, matterText: matterText, error: error, category: category, stale: stale, regenerate: regenerate, }; } SB.useStreamedSummary = useStreamedSummary; })((window as any).SB = (window as any).SB || {});