279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { useAgent } from "agents/react";
|
|
import { useAgentChat } from "@cloudflare/ai-chat/react";
|
|
import { useCustomer } from "autumn-js/react";
|
|
import { useState } from "react";
|
|
import {
|
|
ChatMessage,
|
|
messageHasVisibleContent,
|
|
type ResolveToolLabel,
|
|
} from "@/client/components/chat/ChatMessage";
|
|
import { useStickToBottom } from "@/client/components/chat/useStickToBottom";
|
|
import { captureClientEvent } from "@/client/lib/posthog";
|
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
|
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
|
|
import {
|
|
AUTUMN_CHECKOUT_SESSION_PARAMS,
|
|
AUTUMN_PAID_PLAN_ID,
|
|
} from "@/shared/billing";
|
|
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
|
|
import {
|
|
ChatComposer,
|
|
ChatGate,
|
|
SuggestedQuestions,
|
|
UpgradeSidebar,
|
|
WelcomeMessage,
|
|
} from "./OnboardingChatParts";
|
|
|
|
// Friendly labels for each tool Sam can run, so the chat shows what it's doing
|
|
// rather than going silent while it gathers site data. `running` shows while the
|
|
// call is in flight; `done` stays as a persistent badge once it finishes.
|
|
const TOOL_LABELS: Record<string, { running: string; done: string }> = {
|
|
"tool-read_website": { running: "Reading site", done: "Read site" },
|
|
"tool-get_seo_metrics": {
|
|
running: "Getting SEO metrics",
|
|
done: "SEO metrics",
|
|
},
|
|
"tool-research_keywords": {
|
|
running: "Researching keywords",
|
|
done: "Keyword research",
|
|
},
|
|
"tool-get_domain_overview": {
|
|
running: "Analyzing domain",
|
|
done: "Domain overview",
|
|
},
|
|
"tool-get_serp_results": {
|
|
running: "Checking search results",
|
|
done: "Search results",
|
|
},
|
|
"tool-find_serp_competitors": {
|
|
running: "Finding competitors",
|
|
done: "Competitors",
|
|
},
|
|
"tool-get_competitor_keywords": {
|
|
running: "Analyzing competitor",
|
|
done: "Competitor keywords",
|
|
},
|
|
"tool-get_backlinks_overview": {
|
|
running: "Checking backlinks",
|
|
done: "Backlinks overview",
|
|
},
|
|
};
|
|
|
|
// Onboarding curates a label per tool and hides any tool it hasn't named, so
|
|
// the pre-paywall preview only shows the handful it means to surface.
|
|
const resolveToolLabel: ResolveToolLabel = (partType) =>
|
|
TOOL_LABELS[partType] ?? null;
|
|
|
|
const SUGGESTED_QUESTIONS = [
|
|
"How will OpenSEO help me get more traffic?",
|
|
"Compare OpenSEO and Claude",
|
|
"What do I get after I upgrade?",
|
|
"How does the Google Search Console integration work?",
|
|
"Right fit for consultants and agencies?",
|
|
];
|
|
|
|
// Highlighted (primary) chips shown first, before the general questions.
|
|
// STRATEGY_SUGGESTION drops out once the user has asked for their strategy.
|
|
const STRATEGY_SUGGESTION = "What do you recommend for my site?";
|
|
const COMPETITOR_SUGGESTION = "Compare against my competitors";
|
|
const PRIMARY_SUGGESTIONS = [STRATEGY_SUGGESTION, COMPETITOR_SUGGESTION];
|
|
|
|
export function OnboardingChatConversation({
|
|
projectId,
|
|
domain,
|
|
}: {
|
|
projectId: string;
|
|
domain: string;
|
|
}) {
|
|
// The conversation lives in a Durable Object (Agents SDK), keyed by projectId,
|
|
// so history persists across reloads. The WebSocket connection is authorized
|
|
// in the Worker (src/server.ts) before it reaches the DO; billing gates come
|
|
// back as normal assistant messages rather than HTTP errors.
|
|
const agent = useAgent({ agent: "onboarding-chat", name: projectId });
|
|
// Same constraint as SAM: dense tool-input deltas fan out one store update
|
|
// per chunk and trip React #185 unthrottled (cloudflare/agents#1361).
|
|
const { messages, sendMessage, status } = useAgentChat({
|
|
agent,
|
|
experimental_throttle: 50,
|
|
});
|
|
|
|
// This chat is only ever the pre-upgrade free preview: once a user upgrades
|
|
// they are routed into the GSC onboarding step and never return here, so
|
|
// there's no "paid" state to model — the question cap always applies.
|
|
const customerQuery = useCustomer();
|
|
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
|
const [isStartingCheckout, setIsStartingCheckout] = useState(false);
|
|
const [usedSuggestions, setUsedSuggestions] = useState<string[]>([]);
|
|
// Set once the user asks for their strategy (welcome CTA or the strategy
|
|
// chip) so we don't keep offering the "What do you recommend" chip.
|
|
const [strategyRequested, setStrategyRequested] = useState(false);
|
|
|
|
const questionsUsed = messages.filter((m) => m.role === "user").length;
|
|
const remaining = Math.max(0, FREE_ONBOARDING_QUESTION_LIMIT - questionsUsed);
|
|
const isLocked = remaining <= 0;
|
|
// Nudge once they're within the last few questions, not from the start.
|
|
const showRemainingHint = remaining > 0 && remaining <= 3;
|
|
|
|
const isBusy = status === "submitted" || status === "streaming";
|
|
const { scrollRef, onScroll, pinToBottom } = useStickToBottom(
|
|
messages,
|
|
status,
|
|
);
|
|
const sendText = (text: string) => {
|
|
pinToBottom();
|
|
void sendMessage({ text });
|
|
};
|
|
async function startCheckout() {
|
|
setCheckoutError(null);
|
|
setIsStartingCheckout(true);
|
|
try {
|
|
captureClientEvent("billing:checkout_start");
|
|
// After payment, re-enter onboarding at the GSC step (not back into
|
|
// this chat) so the user finishes connecting Search Console + MCP.
|
|
await customerQuery.attach({
|
|
planId: AUTUMN_PAID_PLAN_ID,
|
|
redirectMode: "always",
|
|
successUrl: buildCheckoutSuccessUrl("/onboarding?step=3"),
|
|
checkoutSessionParams: AUTUMN_CHECKOUT_SESSION_PARAMS,
|
|
});
|
|
} catch (checkoutErr) {
|
|
setCheckoutError(
|
|
getStandardErrorMessage(
|
|
checkoutErr,
|
|
"We couldn't start checkout. Please refresh and try again.",
|
|
),
|
|
);
|
|
setIsStartingCheckout(false);
|
|
}
|
|
}
|
|
|
|
const lastMessage = messages[messages.length - 1];
|
|
const suggestionPool = [
|
|
...(strategyRequested ? [] : [STRATEGY_SUGGESTION]),
|
|
COMPETITOR_SUGGESTION,
|
|
...SUGGESTED_QUESTIONS,
|
|
];
|
|
const remainingSuggestions = suggestionPool.filter(
|
|
(question) => !usedSuggestions.includes(question),
|
|
);
|
|
// Show the typing indicator from the moment the user sends until the
|
|
// assistant's reply shows something — covers the "submitted" wait (last
|
|
// message is still the user's own) and the gap before any text or tool badge
|
|
// renders. Once a tool badge is in flight, it carries the progress, so the
|
|
// dots would just double up.
|
|
const showTyping =
|
|
isBusy &&
|
|
(lastMessage?.role !== "assistant" ||
|
|
!messageHasVisibleContent(lastMessage));
|
|
// Show the chips up front (before the first message) and after each assistant
|
|
// reply, but not while a reply is mid-flight.
|
|
const showSuggestions =
|
|
remainingSuggestions.length > 0 &&
|
|
!isBusy &&
|
|
(messages.length === 0 || lastMessage?.role === "assistant");
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1">
|
|
<UpgradeSidebar
|
|
domain={domain}
|
|
questionsUsed={questionsUsed}
|
|
isStartingCheckout={isStartingCheckout}
|
|
onUpgrade={() => void startCheckout()}
|
|
/>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={onScroll}
|
|
className="flex-1 overflow-y-auto px-5 py-6"
|
|
>
|
|
<div className="mx-auto max-w-2xl space-y-6">
|
|
<WelcomeMessage
|
|
domain={domain}
|
|
checkoutError={checkoutError}
|
|
isStartingCheckout={isStartingCheckout}
|
|
onUpgrade={() => void startCheckout()}
|
|
/>
|
|
|
|
{messages.map((message, index) => (
|
|
<ChatMessage
|
|
key={message.id}
|
|
message={message}
|
|
resolveToolLabel={resolveToolLabel}
|
|
streaming={
|
|
isBusy &&
|
|
index === messages.length - 1 &&
|
|
message.role === "assistant"
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{showTyping ? (
|
|
<div className="flex items-center gap-2 pt-1 text-base-content/40">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
|
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
|
<span className="size-1.5 animate-bounce rounded-full bg-current" />
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{status === "error" ? (
|
|
<p className="text-sm text-error">
|
|
{/* Billing gates (free-question cap / out-of-credits) come
|
|
back as normal assistant messages now, so this only covers
|
|
genuine failures. */}
|
|
Something went wrong. Please refresh and try again.
|
|
</p>
|
|
) : null}
|
|
|
|
{showSuggestions ? (
|
|
<SuggestedQuestions
|
|
questions={remainingSuggestions}
|
|
primaryQuestions={PRIMARY_SUGGESTIONS}
|
|
onSelect={(question) => {
|
|
setUsedSuggestions((current) =>
|
|
current.includes(question)
|
|
? current
|
|
: [...current, question],
|
|
);
|
|
if (question === STRATEGY_SUGGESTION) {
|
|
setStrategyRequested(true);
|
|
}
|
|
sendText(question);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{isLocked ? (
|
|
<ChatGate
|
|
isStartingCheckout={isStartingCheckout}
|
|
onUpgrade={() => void startCheckout()}
|
|
/>
|
|
) : (
|
|
<div className="flex-shrink-0 border-t border-base-300 px-5 py-3">
|
|
<div className="mx-auto w-full max-w-2xl space-y-2">
|
|
{showRemainingHint ? (
|
|
<p className="px-1 text-xs text-base-content/50">
|
|
{remaining} free question{remaining === 1 ? "" : "s"} left.{" "}
|
|
<button
|
|
type="button"
|
|
className="link link-primary"
|
|
disabled={isStartingCheckout}
|
|
onClick={() => void startCheckout()}
|
|
>
|
|
Upgrade for full access
|
|
</button>
|
|
</p>
|
|
) : null}
|
|
<ChatComposer busy={isBusy} onSend={sendText} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|