improve onboarding chat ux + migrated to DO

* Add onboarding agent v1 product spec

* Add onboarding agent implementation plan (Project Think)

* Update onboarding plan: chat + seed function (drop Think/Workflows)

* feat(onboarding): data + metering foundation, Project Context store, MCP tool

* feat(onboarding): site read + DataForSEO signal + OpenRouter strategy seed

* feat(onboarding): strategy + streaming chat UI with update_project_context tool

* fix(onboarding): address review — bound free runs, cap chat, share auth+error helpers, harden scrape

* fix(onboarding): use canonical keyword-locations list, not a separate country list

* Improve onboarding strategy chat

* feat(onboarding): refine upgrade rail UI + fact-checked copy

- Rebuild upgrade sidebar: drop the nested card so the rail itself is the
  container (header / plan / features / CTA / progress footer with dividers)
- Remove the 'Free preview' badge + headline pitch; header now reads
  'Previewing OpenSEO' with the site domain beneath
- Tighten copy against the fact sheet: fix monthly-vs-top-up credit wording,
  drop 'live' rank tracking, add money-back + open-source trust signals,
  unify CTAs to 'Upgrade to continue', cut cross-panel feature redundancy
- Replace off-strategy suggested question; add progress bar counter
- FORCE_FREE_PREVIEW flag to always show the preview/limit UI while testing

* feat(onboarding): add 'What do you recommend' strategy chip; revert suggested questions

- Add a highlighted suggestion chip that prompts Sam for the strategy, shown
  only when the user hasn't already used the welcome 'Show my strategy' CTA
- Track strategyRequested so the chip isn't re-offered after use
- Restore the original four suggested questions

* feat(onboarding): add OpenSEO Discord CTA + fact-sheet entry

- Discord link in the upgrade sidebar
- Fact-sheet community entry + system-prompt guidance so Sam can point
  users to the Discord for community/second-opinion help

* chore(merge-ready): round 1 fixes

- scrape.ts SSRF: validate the initial domain via audit/url-policy
  (normalizeAndValidateStartUrl) and re-validate each redirect hop with
  redirect:"manual" (one hop, blocked/private/metadata hosts + DoH rebinding).
  Replace the content-length-only guard with a bounded streaming read so
  chunked/CDN responses can't buffer past MAX_RESPONSE_BYTES. Remove the
  unguarded normalizeDomainToUrl helper. Add scrape.test.ts.
- http-errors.ts: map PAYMENT_REQUIRED AppError to HTTP 402 (was 500), so the
  onboarding chat paywall backstop surfaces correctly.
- OnboardingStrategyChat: replace the hardcoded FORCE_FREE_PREVIEW=true debug
  flag (which forced paid users into the free-preview/paywall UI) with a
  safe-by-default ?preview=1 URL override.
- onboardingStrategy.ts: delete the dead generateOnboardingStrategy export
  (knip) and its now-unused imports; the chat tool path uses runOnboardingSeed.
- chat.ts: rename inner runOnboardingSeed result to fix no-shadow.
- Extract presentational chat sub-components into OnboardingStrategyChatParts
  to satisfy max-lines; reformat Markdown.tsx for prettier.

* chore(merge-ready): round 2 fixes

- chat.ts: validate message role in schema + count total messages (not just user-role) so the free-question gate can't be bypassed with mislabelled roles
- OnboardingStrategyChat.tsx: surface useChat error state with a paywall-aware notice; branch 'Ask about OpenSEO' message text on isPaid
- OnboardingStrategyChatParts.tsx: guard free-preview welcome copy behind !isPaid (paid variant for subscribers)
- onboardingStrategy.ts: reset onboardingRunStatus/onboardingRunAt when the domain changes so a corrected domain can get a fresh free seed

* chore(merge-ready): round 3 fixes

- onboarding chat: count only user-role messages for free-question paywall to match client gate (was counting all messages, firing ~3 turns early)
- ProjectContextStore: drop unused return value/type from saveProjectContextVersion, inline latest-version query into getCurrentProjectContextMarkdown, remove dead toVersion helper and ProjectContextVersion type
- onboarding chat UI: replace 'Why is OpenSEO better than Claude?' suggested chip with 'How does OpenSEO work with Claude?' (Claude is an MCP client, not a competitor)

* feat(onboarding): route post-upgrade to GSC step; drop isPaid from preview chat

- Checkout successUrl now returns to /onboarding?step=3 (GSC connect) instead
  of the strategy chat, with a 'You're in!' success banner introducing the
  remaining GSC + MCP setup steps. Fixes the post-Stripe 'stuck on paywall'
  race since the user leaves the chat entirely.
- The strategy chat is now purely the pre-upgrade free preview: removed the
  managed-access query, the isPaid branching, and the ?preview override. The
  7-question cap always applies (kept as a conversion funnel).

* feat(onboarding): show post-upgrade success as its own step screen

Instead of a banner stacked above the GSC step, render a 'You're in!' screen
using the standard step layout (logo, title, card, Continue) in place of the
GSC step when ?checkout=success is present. Continue drops the param to reveal
the actual GSC step.

* refactor(nav): remove Project settings from account dropdown

Project settings is now reachable only via the project switcher's 'Manage
projects' → /projects → per-project settings. Drops the dead
projectSettingsLinkOptions helper and the now-unused AccountMenu projectId prop.

* refactor(onboarding): remove project-context persistence + MCP tool

Defers the Project Context store to a later PR to simplify this one.

- Delete ProjectContextStore, the get_project_context MCP tool (+ registration),
  the project_context_versions table (schema + migration 0024 + snapshot), and
  the update_project_context chat tool.
- generate_initial_strategy now returns the synthesized strategy to the chat
  without persisting it; claimRun still gates paid spend (one free run).
- Chat system prompt no longer injects saved context; it just grounds Sam with
  the project's domain.
- getOnboardingStrategyState returns only { projectId, domain }.
- Move the agent fact sheet out of docs/ (human docs) to
  src/server/features/onboarding/openseo-fact-sheet.md.

* refactor(routing): move /strategy to /onboarding/chat

Rename the onboarding strategy chat route from /strategy to /onboarding/chat.
_authenticated.onboarding.tsx becomes the index route; the chat is a sibling,
so TanStack auto-creates the shared /onboarding parent (Outlet).

* chore(onboarding): clean up leftovers from the persistence removal

- Drop the chat's onFinish=invalidateStrategyState refetch: now that the
  strategy state is just { projectId, domain } (no persisted markdown), the
  chat can't mutate it, so the post-turn refetch was dead work.
- Fix a stale 'Project Context' doc comment in synthesis.ts.
- Note in specs 0005/0006 that strategy persistence + the MCP read tool were
  deferred, so the docs don't contradict the shipped code.

* feat(onboarding): meter LLM (OpenRouter) spend via Autumn track_tokens

Mirror the DataForSEO metering pattern for LLM cost: a best-effort
trackLlmUsage helper emits a PostHog usage event and records token usage on
Autumn's token-tracking endpoint (REST; not in the autumn-js SDK yet), priced
from the model slug. Wired into the chat stream (onFinish) and the strategy
synthesis call. getOnboardingModel now also returns the resolved model slug.

* feat(onboarding): step-styled site form + account menu on chat page

- Restyle the website/country form to match the onboarding step layout (logo,
  title, helper) and explain why we ask (read the site + pick the search market).
- Extract OnboardingAccountMenu to a shared component and render it on the
  onboarding chat page so signed-in users can reach account actions there too.

* fix(auth): keep verify-email on 'check your inbox' after email sign-up

The post-sign-up redirect always passes ?email=; key the waiting state off it
so a just-signed-up user sees check-your-inbox + resend instead of the sign-in
CTA the verification gate would immediately block, even while the session is
still resolving.

* fix(onboarding): meter total LLM usage across all stream steps

Review caught that streamText onFinish 'usage' is only the last step; with
stopWhen=4 + the strategy tool, multi-step runs under-metered. Use 'totalUsage'
and await the metering so it fires before the stream closes. Also drop the
in-flux cache/reasoning token fields (negligible here).

* feat(onboarding): adopt the 'chat with tools' architecture from agent-onboarding-2

Replace the deterministic seed + synthesis pipeline (and the
onboarding_run_status/run_at columns) with two on-demand tools Sam calls —
read_website and get_seo_metrics — and have Sam write the strategy itself
in-stream, so a mid-stream refresh re-runs cache-backed tools instead of
dead-ending on a 'complete' status. Rename OnboardingStrategy* -> OnboardingChat*.

Preserved from this branch: LLM metering (now via the chat onFinish totalUsage,
covering the in-stream strategy), the account menu on the chat page, the
verify-email fix, and the step-styled site form. Drop columns via migration 0025.

* docs(onboarding): correct spend-bound + stale synthesis comments

Clarify that get_seo_metrics spend is bounded by the question cap + one project
per un-upgraded account (not solely caching, which doesn't cover no-data sites),
and drop 'synthesis' from comments now that Sam writes the strategy in-stream.

* refactor(onboarding): metered LLM via Autumn AI-SDK adapter; drop skipBalanceAssert

Now that every org gets an onboarding_plan with usage credits, onboarding spend
draws down the normal balance — no bypass needed.

- LLM metering: use Autumn's official @useautumn/gateway adapter (withLlmMetering
  wraps the model; correct token-pool pricing for cached/reasoning tokens),
  replacing the hand-rolled onFinish/track_tokens REST plumbing. Point it at the
  existing 'llm_usage' feature (backed by usage_credits + topup_credits) rather
  than a to-be-created 'ai_credits' feature.
- DataForSEO: remove skipBalanceAssert end-to-end (chat metering object, the
  meter() plumbing in dataforseo/client.ts, and the DomainService override type);
  onboarding now asserts balance like every other caller. Kept the email-verified
  + Labs-location gate on get_seo_metrics as the anti-farming bound.

Co-authored with a parallel agent's LLM-metering refactor.

* docs(onboarding): fix stale metering comment + diverged-architecture specs

- DomainService MeteringOverrides comment no longer claims a balance-gate bypass
  (skipBalanceAssert + the onboarding seed are gone).
- specs 0005/0006: correct the update notes — the seed/synthesis pipeline,
  claimRun, and skipBalanceAssert were replaced by the chat-with-tools design;
  flag the bodies as the superseded plan.
- Document the pinned Autumn track_tokens API version.

* feat(onboarding): gate the chat turn on credit balance (LLM included)

Now that every org gets onboarding_plan trial credits and LLM tokens draw from
the same usage/topup balance, assert that balance before streaming — not just
track it. Extract the DataForSEO balance check into subscription.ts
(getUsageCreditsRemaining / assertUsageCreditsAvailable) and reuse it; the chat
throws a friendly PAYMENT_REQUIRED when credits are gone (client shows the
upgrade copy).

* feat(onboarding): make the strategy chat hosted-only

The chat needs the managed LLM + trial credits, so self-hosted has no business
there. Gate the step-2 navigation on hosted mode and add a beforeLoad redirect
on /onboarding/chat so self-hosted lands back in the wizard.

* feat(onboarding): site-form + welcome copy; drop open-source badge

- Site form: 'Tell us about your website.' title, short input labels, no extra
  helper descriptions.
- Welcome message: lead with the upgrade ask + a Discord/email escape hatch.
- Remove the 'Open source — self-host for free anytime' badge from the rail.

* fix(onboarding): show typing indicator during the submitted wait

showTyping gated on the last message lacking assistant text, but right after
send the last message is the user's own (which has text), so nothing showed
until the assistant message appeared. Show it whenever busy and the last
message isn't assistant-text-yet.

* refactor(onboarding): drop redundant email-verified gate on get_seo_metrics

The route guard already requires a verified email to reach the chat in hosted
mode, and the trial-credit balance bounds spend — so the in-tool emailVerified
check was redundant for real users and blocked local/bypass testing. Keep the
Labs-location check (functional).

* feat(billing): meter onboarding LLM spend into the shared credit pool

Both DataForSEO and onboarding-LLM now draw from the same usage_credits/
topup_credits pool via one helper, instead of LLM needing a separate Autumn
ai_credit_system.

- Extract trackUsageCreditSpend (markup -> credits -> monthly/topup split ->
  autumn.track + usage:credits_consume) into subscription.ts; DataForSEO's
  trackDataforseoCost now delegates to it (behavior unchanged, tests pass).
- Enable OpenRouter usage accounting; the chat onFinish sums the real per-step
  cost OpenRouter reports and deducts it through the same helper.
- Drop the @useautumn/gateway adapter, llm-metering.ts, track_tokens, and the
  AUTUMN_LLM_USAGE_FEATURE_ID constant — no ai_credit_system feature needed.

* feat(onboarding): persist the strategy chat in a Durable Object (AIChatAgent)

Move the onboarding chat from a stateless streamText route to an Agents SDK
AIChatAgent Durable Object, so the conversation persists (DO SQLite) and
survives reloads — one instance per project.

- OnboardingChatAgent.onChatMessage ports the system prompt, read_website +
  get_seo_metrics tools, the credit-balance/free-question gate, and the
  OpenRouter cost metering. Billing gates surface as a normal assistant message
  (staticAssistantResponse) rather than an HTTP 402.
- The Worker authorizes every /agents/* connection (resolve session + verify the
  caller's org owns the projectId) before it reaches the DO; the DO derives org
  /domain from the project it is named after. Auth stays on the proven path.
- Client swaps useChat -> useAgent + useAgentChat (WebSocket), keyed by projectId.
- Adds the DO binding + new_sqlite_classes migration; pins @cloudflare/ai-chat
  0.6.1 to match agents 0.12.3.

* chore(onboarding): bump agents+ai-chat to latest; fix review findings

- Bump agents 0.12.3 -> 0.15.0 and @cloudflare/ai-chat -> 0.8.4 (the supported
  pairing; verified MCP, the DO, and the build still compile).
- Thread the per-turn abortSignal into streamText so a user aborting mid-stream
  cancels the billable LLM call (was leaking sub-cent cost on abort).
- Ensure the org's Autumn customer exists in the Worker authorize step before
  the DO checks the credit balance, avoiding a false 'out of credits' gate on a
  brand-new org's first message.

* chore: remove stray reservation-booker-seo-report.html

* chore: drop stale @useautumn/gateway minimumReleaseAge exclusion

The package was removed when LLM metering moved to the shared credit pool.

* perf(onboarding): fetch get_seo_metrics signals in parallel; clarify question-cap

- get_seo_metrics now fetches the domain overview and ranked keywords
  concurrently instead of in series (faster tool turn). Trade-off: it always
  issues the metered ranked-keywords call now, including for no-ranking sites.
- Correct the FREE_ONBOARDING_QUESTION_LIMIT comment: the server re-check counts
  client-supplied history, so the cap is a conversion nudge, not a security
  boundary — the credit balance is the real spend bound.
This commit is contained in:
Ben Senescu 2026-06-19 18:09:52 -04:00 committed by GitHub
parent e57768a8ce
commit 0652f3a8a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 4616 additions and 930 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE `projects` DROP COLUMN `onboarding_run_status`;--> statement-breakpoint
ALTER TABLE `projects` DROP COLUMN `onboarding_run_at`;

File diff suppressed because it is too large Load Diff

View File

@ -176,6 +176,13 @@
"when": 1781809245835, "when": 1781809245835,
"tag": "0024_clumsy_barracuda", "tag": "0024_clumsy_barracuda",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1781892941214,
"tag": "0025_loving_mojo",
"breakpoints": true
} }
] ]
} }

View File

@ -59,6 +59,7 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.201", "@ai-sdk/react": "^3.0.201",
"@cloudflare/ai-chat": "^0.8.4",
"@cloudflare/workers-oauth-provider": "^0.4.0", "@cloudflare/workers-oauth-provider": "^0.4.0",
"@every-app/sdk": "^0.1.14", "@every-app/sdk": "^0.1.14",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
@ -70,7 +71,7 @@
"@tanstack/react-router-devtools": "^1.166.11", "@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-start": "^1.167.16", "@tanstack/react-start": "^1.167.16",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"agents": "0.12.3", "agents": "0.15.0",
"ai": "^6.0.199", "ai": "^6.0.199",
"autumn-js": "^1.1.7", "autumn-js": "^1.1.7",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",

1219
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -4,10 +4,13 @@
Proposed (June 2026) — v1 product spec, pending technical design. Proposed (June 2026) — v1 product spec, pending technical design.
> **Update (June 2026):** the first PR ships the chat + live strategy generation > **Update (June 2026):** what shipped is a chat where Sam analyzes the site
> only. Persisting the strategy (the "Project Context" store, R2 versioning) and > on demand (via `read_website` + `get_seo_metrics` tools) and writes the
> the `get_project_context` MCP tool described below are **deferred to a later > strategy in-stream, rather than the staged synthesize → persist pipeline
> PR** — the generated strategy is shown in the chat but not yet saved. > described below. Persisting the strategy (the "Project Context" store + R2
> versioning) and the `get_project_context` MCP tool are **deferred to a later
> PR** — the strategy is shown in the chat but not yet saved. Sections below
> describe the original plan.
## Goal ## Goal

View File

@ -7,13 +7,16 @@ Accepted (June 2026) — technical plan for `specs/0005-onboarding-agent.md`.
Supersedes an earlier draft that proposed Cloudflare Project Think + Durable Supersedes an earlier draft that proposed Cloudflare Project Think + Durable
Objects + a Workflow. We dropped all of that (see "Why not Think / Workflows"). Objects + a Workflow. We dropped all of that (see "Why not Think / Workflows").
> **Update (June 2026):** to keep the first PR small, strategy **persistence** > **Update (June 2026):** the shipped implementation diverged from the plan
> (the `project_context_versions` store + R2 markdown versioning) and the > below. The deterministic seed + synthesis pipeline — and its `claimRun`
> `get_project_context` **MCP tool** were deferred to a later PR. `seed.ts` now > run-status guard and `skipBalanceAssert` bypass — was replaced by an on-demand
> returns the synthesized strategy to the chat without saving it; the > chat: Sam calls two tools (`read_website`, `get_seo_metrics`) and writes the
> `claimRun` spend guard still bounds the paid run. Sections below describing > strategy itself in-stream (see `src/routes/api/onboarding/chat.ts`). Strategy
> the store, versioning, and the MCP read tool reflect the original plan, not > **persistence** (the `project_context_versions` store + R2 versioning) and the
> what shipped. > `get_project_context` **MCP tool** are deferred to a later PR. Onboarding
> spend — DataForSEO **and** LLM tokens — draws down the org's onboarding-plan
> trial credits via the normal balance gate. The sections below describe the
> original plan, not what shipped.
## TL;DR ## TL;DR

View File

@ -0,0 +1,53 @@
import { Settings, User } from "lucide-react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { signOutAndRedirect } from "@/lib/auth-client";
// Account dropdown shared by the onboarding wizard and the onboarding chat so a
// signed-in user can reach Settings / theme / sign out from either surface.
// Fixed top-right; renders nothing until we know the user's email.
export function OnboardingAccountMenu({
email,
}: {
email: string | undefined;
}) {
if (!email) return null;
const handleSignOut = () => signOutAndRedirect();
return (
<div className="fixed top-4 right-4">
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
className="btn btn-ghost btn-circle"
aria-label="Open account menu"
>
<User className="h-5 w-5" />
</button>
<ul
tabIndex={0}
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li className="menu-title max-w-full">
<span className="truncate text-base-content" data-ph-mask>
{email}
</span>
</li>
<li>
<a href="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Settings
</a>
</li>
<ThemePreferenceMenuItems />
<li>
<button type="button" onClick={handleSignOut}>
Sign out
</button>
</li>
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,143 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { AutumnProvider } from "autumn-js/react";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import {
DEFAULT_LOCATION_CODE,
LOCATION_OPTIONS,
} from "@/shared/keyword-locations";
import { useSession } from "@/lib/auth-client";
import { saveOnboardingSite } from "@/serverFunctions/onboardingChat";
import { OnboardingAccountMenu } from "./OnboardingAccountMenu";
import { OnboardingChatConversation } from "./OnboardingChatConversation";
import {
invalidateOnboardingChatState,
onboardingChatStateQueryOptions,
} from "./onboardingChatQueries";
// Full-viewport chat surface. Breaks out of the centered, padded AuthPageShell
// with `fixed inset-0` so the chat fills the screen. There's no header bar —
// the strategy's first message carries the context — and inner content is
// constrained to a narrow column for comfortable reading width.
function StrategyShell({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 flex flex-col bg-base-100">{children}</div>
);
}
export function OnboardingChat() {
const stateQuery = useQuery(onboardingChatStateQueryOptions());
const { data: session } = useSession();
const accountMenu = <OnboardingAccountMenu email={session?.user?.email} />;
if (stateQuery.isError) {
return (
<StrategyShell>
<div className="flex flex-1 items-center justify-center p-6 text-sm text-error">
Couldnt load your strategy. Please refresh to try again.
</div>
</StrategyShell>
);
}
if (!stateQuery.data) {
return (
<StrategyShell>
<div className="flex flex-1 items-center justify-center gap-2 p-6 text-sm text-base-content/60">
<Loader2 className="size-4 animate-spin" />
Loading
</div>
</StrategyShell>
);
}
const { projectId, domain } = stateQuery.data;
return (
<StrategyShell>
{accountMenu}
{!domain ? (
<SiteForm projectId={projectId} />
) : (
<AutumnProvider>
<OnboardingChatConversation projectId={projectId} domain={domain} />
</AutumnProvider>
)}
</StrategyShell>
);
}
function SiteForm({ projectId }: { projectId: string }) {
const [domain, setDomain] = useState("");
const [locationCode, setLocationCode] = useState(DEFAULT_LOCATION_CODE);
const save = useMutation({
mutationFn: () =>
saveOnboardingSite({ data: { projectId, domain, locationCode } }),
onSuccess: invalidateOnboardingChatState,
});
return (
<div className="flex flex-1 items-center justify-center overflow-y-auto p-6">
<form
className="w-full max-w-md space-y-6"
onSubmit={(event) => {
event.preventDefault();
if (domain.trim()) {
save.mutate();
}
}}
>
<div className="space-y-3 text-center">
<img
src="/transparent-logo.png"
alt="OpenSEO"
className="mx-auto size-10 rounded-lg"
/>
<h1 className="text-xl font-semibold">Tell us about your website.</h1>
<p className="text-sm text-base-content/60">
If you have multiple websites, you can set that up later.
</p>
</div>
<div className="space-y-4 rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm">
<label className="block space-y-1">
<span className="text-sm font-medium">Your website</span>
<input
type="text"
className="input input-bordered w-full"
placeholder="example.com"
value={domain}
onChange={(event) => setDomain(event.target.value)}
/>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium">
This is the country we will use when getting SEO data.
</span>
<select
className="select select-bordered w-full"
value={locationCode}
onChange={(event) => setLocationCode(Number(event.target.value))}
>
{LOCATION_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</label>
<button
type="submit"
className="btn btn-primary w-full"
disabled={!domain.trim() || save.isPending}
>
{save.isPending ? "Saving…" : "Continue"}
</button>
</div>
</form>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { useChat } from "@ai-sdk/react"; import { useAgent } from "agents/react";
import { DefaultChatTransport, type UIMessage } from "ai"; import { useAgentChat } from "@cloudflare/ai-chat/react";
import { type UIMessage } from "ai";
import { useCustomer } from "autumn-js/react"; import { useCustomer } from "autumn-js/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
@ -13,7 +14,7 @@ import {
SuggestedQuestions, SuggestedQuestions,
UpgradeSidebar, UpgradeSidebar,
WelcomeMessage, WelcomeMessage,
} from "./OnboardingStrategyChatParts"; } from "./OnboardingChatParts";
function messageHasText(message: UIMessage): boolean { function messageHasText(message: UIMessage): boolean {
return message.parts.some( return message.parts.some(
@ -21,6 +22,33 @@ function messageHasText(message: UIMessage): boolean {
); );
} }
// While Sam is running a tool, surface what it's doing so the wait reads as
// progress, not a hang — gathering site data takes a few seconds before any
// text streams back.
function activeToolLabel(
message: UIMessage | undefined,
domain: string,
): string | null {
if (!message || message.role !== "assistant") return null;
for (const part of message.parts) {
if (typeof part.type !== "string" || !part.type.startsWith("tool-")) {
continue;
}
// Tool parts carry a `state`; skip ones that have already finished so the
// label only shows while a tool is actually in flight.
if (
"state" in part &&
(part.state === "output-available" || part.state === "output-error")
) {
continue;
}
if (part.type === "tool-read_website") return `Reading ${domain}`;
if (part.type === "tool-get_seo_metrics") return "Checking your rankings…";
return "Researching your site…";
}
return null;
}
function ChatBubble({ message }: { message: UIMessage }) { function ChatBubble({ message }: { message: UIMessage }) {
const isUser = message.role === "user"; const isUser = message.role === "user";
@ -67,19 +95,19 @@ const SUGGESTED_QUESTIONS = [
// their strategy via the welcome CTA. Clicking it prompts Sam to draft/show it. // their strategy via the welcome CTA. Clicking it prompts Sam to draft/show it.
const STRATEGY_SUGGESTION = "What do you recommend for my site?"; const STRATEGY_SUGGESTION = "What do you recommend for my site?";
export function StrategyChat({ export function OnboardingChatConversation({
projectId, projectId,
domain, domain,
}: { }: {
projectId: string; projectId: string;
domain: string; domain: string;
}) { }) {
const { messages, sendMessage, status, error } = useChat({ // The conversation lives in a Durable Object (Agents SDK), keyed by projectId,
transport: new DefaultChatTransport({ // so history persists across reloads. The WebSocket connection is authorized
api: "/api/onboarding/chat", // in the Worker (src/server.ts) before it reaches the DO; billing gates come
body: { projectId }, // back as normal assistant messages rather than HTTP errors.
}), const agent = useAgent({ agent: "onboarding-chat", name: projectId });
}); const { messages, sendMessage, status } = useAgentChat({ agent });
// This chat is only ever the pre-upgrade free preview: once a user upgrades // 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 // they are routed into the GSC onboarding step and never return here, so
@ -132,7 +160,6 @@ export function StrategyChat({
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
}, [messages, status]); }, [messages, status]);
// Show a typing indicator until the assistant's reply starts streaming text.
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
const suggestionPool = strategyRequested const suggestionPool = strategyRequested
? SUGGESTED_QUESTIONS ? SUGGESTED_QUESTIONS
@ -140,7 +167,13 @@ export function StrategyChat({
const remainingSuggestions = suggestionPool.filter( const remainingSuggestions = suggestionPool.filter(
(question) => !usedSuggestions.includes(question), (question) => !usedSuggestions.includes(question),
); );
const showTyping = isBusy && (!lastMessage || !messageHasText(lastMessage)); // Show the typing indicator from the moment the user sends until the
// assistant's reply has visible text — covers the "submitted" wait, when the
// last message is still the user's own (so it can't gate on assistant text).
const showTyping =
isBusy &&
(lastMessage?.role !== "assistant" || !messageHasText(lastMessage));
const toolLabel = activeToolLabel(lastMessage, domain);
const showSuggestions = const showSuggestions =
remainingSuggestions.length > 0 && remainingSuggestions.length > 0 &&
!isBusy && !isBusy &&
@ -185,10 +218,15 @@ export function StrategyChat({
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="size-4" /> <Sparkles className="size-4" />
</div> </div>
<div className="flex items-center gap-1.5 pt-2 text-base-content/40"> <div className="flex items-center gap-2 pt-2 text-base-content/40">
{toolLabel ? (
<span className="text-sm">{toolLabel}</span>
) : null}
<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.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 [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current" /> <span className="size-1.5 animate-bounce rounded-full bg-current" />
</span>
</div> </div>
</div> </div>
) : null} ) : null}
@ -199,11 +237,10 @@ export function StrategyChat({
<Sparkles className="size-4" /> <Sparkles className="size-4" />
</div> </div>
<p className="pt-1 text-sm text-error"> <p className="pt-1 text-sm text-error">
{/* useChat surfaces the failed response body as error.message; {/* Billing gates (free-question cap / out-of-credits) come
the server's 402 backstop sends the paywall copy below. */} back as normal assistant messages now, so this only covers
{(error?.message ?? "").includes("free strategy questions") genuine failures. */}
? "You've reached the free question limit. Upgrade to continue." Something went wrong. Please refresh and try again.
: "Something went wrong. Please refresh and try again."}
</p> </p>
</div> </div>
) : null} ) : null}

View File

@ -5,14 +5,7 @@ import {
type FormEvent, type FormEvent,
type KeyboardEvent, type KeyboardEvent,
} from "react"; } from "react";
import { import { ArrowUp, Check, Globe, Loader2, Sparkles } from "lucide-react";
ArrowUp,
Check,
Globe,
Loader2,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat"; import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
const DISCORD_URL = "https://discord.gg/c9uGs3cFXr"; const DISCORD_URL = "https://discord.gg/c9uGs3cFXr";
@ -80,10 +73,24 @@ export function WelcomeMessage({
<div className="space-y-3 text-base-content/80"> <div className="space-y-3 text-base-content/80">
<p>Hey, Im Sam welcome to OpenSEO.</p> <p>Hey, Im Sam welcome to OpenSEO.</p>
<p> <p>
Youre on the free preview: {FREE_ONBOARDING_QUESTION_LIMIT}{" "} To get full access to OpenSEO, you need to upgrade to the paid plan.
questions with me before you upgrade. I can draft an SEO strategy But, Im here if you have any questions.
for <span className="font-medium text-base-content">{domain}</span>{" "} </p>
and answer questions about OpenSEO or SEO. <p>
You can also{" "}
<a
href={DISCORD_URL}
target="_blank"
rel="noreferrer"
className="link link-primary"
>
join the Discord
</a>{" "}
or email{" "}
<a href="mailto:ben@openseo.so" className="link link-primary">
ben@openseo.so
</a>{" "}
if you have any questions I cant help you with.
</p> </p>
<p> <p>
Want me to analyze{" "} Want me to analyze{" "}
@ -203,10 +210,6 @@ export function UpgradeSidebar({
> >
{isStartingCheckout ? "Redirecting..." : "Upgrade to continue"} {isStartingCheckout ? "Redirecting..." : "Upgrade to continue"}
</button> </button>
<p className="flex items-center justify-center gap-1.5 text-center text-xs text-base-content/45">
<ShieldCheck className="size-3.5" />
Open source self-host for free anytime
</p>
<p className="text-center text-xs leading-relaxed text-base-content/55"> <p className="text-center text-xs leading-relaxed text-base-content/55">
Want advice from other OpenSEO users?{" "} Want advice from other OpenSEO users?{" "}
<a <a

View File

@ -1,125 +0,0 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { AutumnProvider } from "autumn-js/react";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import {
DEFAULT_LOCATION_CODE,
LOCATION_OPTIONS,
} from "@/shared/keyword-locations";
import { saveOnboardingSite } from "@/serverFunctions/onboardingStrategy";
import { StrategyChat } from "./OnboardingStrategyChat";
import {
invalidateStrategyState,
strategyStateQueryOptions,
} from "./onboardingStrategyQueries";
// Full-viewport chat surface. Breaks out of the centered, padded AuthPageShell
// with `fixed inset-0` so the chat fills the screen. There's no header bar —
// the strategy's first message carries the context — and inner content is
// constrained to a narrow column for comfortable reading width.
function StrategyShell({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 flex flex-col bg-base-100">{children}</div>
);
}
export function OnboardingStrategy() {
const stateQuery = useQuery(strategyStateQueryOptions());
if (stateQuery.isError) {
return (
<StrategyShell>
<div className="flex flex-1 items-center justify-center p-6 text-sm text-error">
Couldnt load your strategy. Please refresh to try again.
</div>
</StrategyShell>
);
}
if (!stateQuery.data) {
return (
<StrategyShell>
<div className="flex flex-1 items-center justify-center gap-2 p-6 text-sm text-base-content/60">
<Loader2 className="size-4 animate-spin" />
Loading
</div>
</StrategyShell>
);
}
const { projectId, domain } = stateQuery.data;
return (
<StrategyShell>
{!domain ? (
<SiteForm projectId={projectId} />
) : (
<AutumnProvider>
<StrategyChat projectId={projectId} domain={domain} />
</AutumnProvider>
)}
</StrategyShell>
);
}
function SiteForm({ projectId }: { projectId: string }) {
const [domain, setDomain] = useState("");
const [locationCode, setLocationCode] = useState(DEFAULT_LOCATION_CODE);
const save = useMutation({
mutationFn: () =>
saveOnboardingSite({ data: { projectId, domain, locationCode } }),
onSuccess: invalidateStrategyState,
});
return (
<div className="flex flex-1 items-center justify-center overflow-y-auto p-6">
<form
className="w-full max-w-sm space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (domain.trim()) {
save.mutate();
}
}}
>
<label className="block space-y-1">
<span className="text-sm font-medium">Your website</span>
<input
type="text"
className="input input-bordered w-full"
placeholder="example.com"
value={domain}
onChange={(event) => setDomain(event.target.value)}
/>
<span className="text-xs text-base-content/50">
You can add more projects with different websites later.
</span>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium">Primary country</span>
<select
className="select select-bordered w-full"
value={locationCode}
onChange={(event) => setLocationCode(Number(event.target.value))}
>
{LOCATION_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</label>
<button
type="submit"
className="btn btn-primary w-full"
disabled={!domain.trim() || save.isPending}
>
{save.isPending ? "Saving…" : "Continue"}
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { queryOptions } from "@tanstack/react-query";
import { queryClient } from "@/client/tanstack-db";
import { getOnboardingChatState } from "@/serverFunctions/onboardingChat";
export const onboardingChatStateQueryOptions = () =>
queryOptions({
queryKey: ["onboardingChatState"],
queryFn: () => getOnboardingChatState(),
});
export function invalidateOnboardingChatState() {
void queryClient.invalidateQueries({ queryKey: ["onboardingChatState"] });
}

View File

@ -1,13 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { queryClient } from "@/client/tanstack-db";
import { getOnboardingStrategyState } from "@/serverFunctions/onboardingStrategy";
export const strategyStateQueryOptions = () =>
queryOptions({
queryKey: ["onboardingStrategyState"],
queryFn: () => getOnboardingStrategyState(),
});
export function invalidateStrategyState() {
void queryClient.invalidateQueries({ queryKey: ["onboardingStrategyState"] });
}

View File

@ -54,10 +54,6 @@ export const projects = sqliteTable(
// onboarding and reused by every project-scoped data call. // onboarding and reused by every project-scoped data call.
locationCode: integer("location_code").notNull().default(2840), locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"), languageCode: text("language_code").notNull().default("en"),
// Onboarding strategy generation run state. Null = never run; the atomic
// transition null -> 'running' is the at-most-once guard for the (free) run.
onboardingRunStatus: text("onboarding_run_status"),
onboardingRunAt: text("onboarding_run_at"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),

5
src/env.d.ts vendored
View File

@ -6,6 +6,9 @@ declare namespace Cloudflare {
R2: R2Bucket; R2: R2Bucket;
OAUTH_KV: KVNamespace; OAUTH_KV: KVNamespace;
// Durable Object backing the onboarding strategy chat (see wrangler.jsonc).
ONBOARDING_CHAT: DurableObjectNamespace;
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
BYPASS_EMAIL_VERIFICATION?: string; BYPASS_EMAIL_VERIFICATION?: string;
TEAM_DOMAIN?: string; TEAM_DOMAIN?: string;
@ -25,7 +28,7 @@ declare namespace Cloudflare {
// DataForSEO API Basic auth value (base64 of login:password) // DataForSEO API Basic auth value (base64 of login:password)
DATAFORSEO_API_KEY: string; DATAFORSEO_API_KEY: string;
// OpenRouter API key for onboarding strategy synthesis + chat. // OpenRouter API key for the onboarding chat.
OPENROUTER_API_KEY?: string; OPENROUTER_API_KEY?: string;
// Optional OpenRouter model slug override (defaults in openrouter.ts). // Optional OpenRouter model slug override (defaults in openrouter.ts).
OPENROUTER_MODEL?: string; OPENROUTER_MODEL?: string;

View File

@ -28,7 +28,6 @@ import { Route as AppBillingRouteImport } from './routes/_app/billing'
import { Route as AppAiRouteImport } from './routes/_app/ai' import { Route as AppAiRouteImport } from './routes/_app/ai'
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index' import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
import { Route as ApiOnboardingChatRouteImport } from './routes/api/onboarding/chat'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat' import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
@ -144,11 +143,6 @@ const AuthenticatedOnboardingIndexRoute =
path: '/onboarding/', path: '/onboarding/',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const ApiOnboardingChatRoute = ApiOnboardingChatRouteImport.update({
id: '/api/onboarding/chat',
path: '/api/onboarding/chat',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({ const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$', id: '/api/autumn/$',
path: '/api/autumn/$', path: '/api/autumn/$',
@ -281,7 +275,6 @@ export interface FileRoutesByFullPath {
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute '/onboarding/': typeof AuthenticatedOnboardingIndexRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -318,7 +311,6 @@ export interface FileRoutesByTo {
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
'/onboarding': typeof AuthenticatedOnboardingIndexRoute '/onboarding': typeof AuthenticatedOnboardingIndexRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -359,7 +351,6 @@ export interface FileRoutesById {
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute '/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -399,7 +390,6 @@ export interface FileRouteTypes {
| '/onboarding/chat' | '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/onboarding/chat'
| '/onboarding/' | '/onboarding/'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
@ -436,7 +426,6 @@ export interface FileRouteTypes {
| '/onboarding/chat' | '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/onboarding/chat'
| '/onboarding' | '/onboarding'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
@ -476,7 +465,6 @@ export interface FileRouteTypes {
| '/_authenticated/onboarding/chat' | '/_authenticated/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/onboarding/chat'
| '/_authenticated/onboarding/' | '/_authenticated/onboarding/'
| '/_project/p/$projectId/audit' | '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
@ -506,7 +494,6 @@ export interface RootRouteChildren {
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
ApiOnboardingChatRoute: typeof ApiOnboardingChatRoute
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
} }
@ -645,13 +632,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/api/onboarding/chat': {
id: '/api/onboarding/chat'
path: '/api/onboarding/chat'
fullPath: '/api/onboarding/chat'
preLoaderRoute: typeof ApiOnboardingChatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/autumn/$': { '/api/autumn/$': {
id: '/api/autumn/$' id: '/api/autumn/$'
path: '/api/autumn/$' path: '/api/autumn/$'
@ -941,7 +921,6 @@ const rootRouteChildren: RootRouteChildren = {
Char91DotwellKnownChar93OpenaiAppsChallengeRoute, Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute,
ApiOnboardingChatRoute: ApiOnboardingChatRoute,
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute, ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@ -1,6 +1,14 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { OnboardingStrategy } from "@/client/features/onboarding/OnboardingStrategy"; import { OnboardingChat } from "@/client/features/onboarding/OnboardingChat";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/_authenticated/onboarding/chat")({ export const Route = createFileRoute("/_authenticated/onboarding/chat")({
component: OnboardingStrategy, // The strategy chat is hosted-only (managed LLM + trial credits). Self-hosted
// has no business here — send it back to the onboarding wizard.
beforeLoad: () => {
if (!isHostedClientAuthMode()) {
throw redirect({ to: "/onboarding", search: { step: 3 }, replace: true });
}
},
component: OnboardingChat,
}); });

View File

@ -1,8 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Settings, User } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { OnboardingAccountMenu } from "@/client/features/onboarding/OnboardingAccountMenu";
import { PostSignupOnboarding } from "@/client/features/onboarding/PostSignupOnboarding"; import { PostSignupOnboarding } from "@/client/features/onboarding/PostSignupOnboarding";
import { import {
buildOnboardingPayload, buildOnboardingPayload,
@ -14,7 +13,7 @@ import {
import { managedAccessQueryOptions } from "@/client/features/billing/managed-access"; import { managedAccessQueryOptions } from "@/client/features/billing/managed-access";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { queryClient } from "@/client/tanstack-db"; import { queryClient } from "@/client/tanstack-db";
import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { SUBSCRIBE_ROUTE } from "@/shared/billing"; import { SUBSCRIBE_ROUTE } from "@/shared/billing";
import { saveOnboardingAnswers } from "@/serverFunctions/onboarding"; import { saveOnboardingAnswers } from "@/serverFunctions/onboarding";
@ -111,7 +110,10 @@ function OnboardingFlow({
void navigate({ to: "/onboarding", search: { step: clampStep(next) } }); void navigate({ to: "/onboarding", search: { step: clampStep(next) } });
const advanceFromCurrentStep = () => { const advanceFromCurrentStep = () => {
if (step === 2) { // The strategy chat is a hosted-only, pre-paywall surface (it needs the
// managed LLM + trial credits). Self-hosted skips it and continues straight
// to the GSC/MCP steps.
if (step === 2 && isHostedMode) {
void navigate({ to: "/onboarding/chat", replace: true }); void navigate({ to: "/onboarding/chat", replace: true });
return; return;
} }
@ -191,46 +193,3 @@ function OnboardingFlow({
/> />
); );
} }
function OnboardingAccountMenu({ email }: { email: string | undefined }) {
if (!email) return null;
const handleSignOut = () => signOutAndRedirect();
return (
<div className="fixed top-4 right-4">
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
className="btn btn-ghost btn-circle"
aria-label="Open account menu"
>
<User className="h-5 w-5" />
</button>
<ul
tabIndex={0}
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li className="menu-title max-w-full">
<span className="truncate text-base-content" data-ph-mask>
{email}
</span>
</li>
<li>
<a href="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Settings
</a>
</li>
<ThemePreferenceMenuItems />
<li>
<button type="button" onClick={handleSignOut}>
Sign out
</button>
</li>
</ul>
</div>
</div>
);
}

View File

@ -1,162 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import {
convertToModelMessages,
stepCountIs,
streamText,
tool,
type UIMessage,
} from "ai";
import { z } from "zod";
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
import { AppError } from "@/server/lib/errors";
import { responseForAppError } from "@/server/lib/http-errors";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { runOnboardingSeed } from "@/server/features/onboarding/seed";
import { getOnboardingModel } from "@/server/lib/openrouter";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import {
customerHasManagedAccess,
getOrCreateOrganizationCustomer,
} from "@/server/billing/subscription";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import { LOCATIONS } from "@/shared/keyword-locations";
import openSeoFactSheet from "@/server/features/onboarding/openseo-fact-sheet.md?raw";
// Bound the conversation so a single authed user can't drive an unbounded LLM
// context (the chat is a free, pre-paywall surface).
const MAX_MESSAGES = 40;
const bodySchema = z.object({
projectId: z.string().min(1),
// Validate `role` structurally (keeping the UIMessage type) so a caller can't
// smuggle in messages with a bogus role to dodge the free-question count.
messages: z
.array(
z
.custom<UIMessage>()
.and(z.object({ role: z.enum(["user", "assistant", "tool"]) })),
)
.max(MAX_MESSAGES),
});
function buildSystemPrompt(domain: string | null): string {
return [
"You are Sam, the SEO onboarding agent inside OpenSEO. Introduce yourself as Sam if the user asks who you are.",
"Answer SEO questions concisely and practically.",
"Only answer questions related to SEO, OpenSEO, OpenSEO setup, MCP/AI-agent SEO workflows, Google Search Console in OpenSEO, or open-source/self-hosting topics. If the user asks about anything else, politely say you're here to help them get up and running with OpenSEO and ask what they want to know about OpenSEO or SEO.",
"For OpenSEO product questions, use the OpenSEO Fact Sheet below as your source of truth. Do not invent product facts, feature details, pricing, limits, integrations, or support claims. If the fact sheet does not support the answer, say you are not sure and suggest contacting ben@openseo.so.",
"When users want advice from people in the community, a second opinion, or help beyond this onboarding chat, mention the OpenSEO Discord from the fact sheet.",
"When the user asks how OpenSEO helps them get traffic or rank higher, lead with the fact sheet's SEO strategy framing: positioning, topical authority, focused early topics, then expansion into broader searches. Do not answer as only a feature list.",
"OpenSEO is limited until the user upgrades to the paid plan. Be direct about that, but do not hard-sell.",
"When the user asks you to show, propose, generate, draft, or analyze an SEO strategy, call generate_initial_strategy.",
domain
? `The user's website is ${domain}.`
: "If you need the user's website before answering, ask for it briefly.",
`OpenSEO Fact Sheet:\n\n${openSeoFactSheet}`,
].join("\n\n");
}
async function handleChat(request: Request): Promise<Response> {
const context = await resolveUserContextFromHeaders(request.headers);
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
throw new AppError("VALIDATION_ERROR", "Invalid chat request");
}
const body = parsed.data;
// Authorize the requested project against the caller's organization.
const project = await ProjectRepository.getProjectForOrganization(
body.projectId,
context.organizationId,
);
if (!project) {
throw new AppError("FORBIDDEN");
}
// Free-plan cap. The client disables the composer once the limit is reached,
// so this is the server-side backstop for a request that bypassed it. Only
// pay for the (network) access check once a request is actually over the
// limit — the common case stays a single round-trip.
// Count only user-role messages to match the client gate (roles are already
// validated by bodySchema above).
const questionCount = body.messages.filter((m) => m.role === "user").length;
if (questionCount > FREE_ONBOARDING_QUESTION_LIMIT) {
const hosted = await isHostedServerAuthMode();
if (hosted) {
const customer = await getOrCreateOrganizationCustomer(context);
if (!(await customerHasManagedAccess(customer.id))) {
throw new AppError(
"PAYMENT_REQUIRED",
"You've used all your free strategy questions. Subscribe to continue.",
);
}
}
}
const model = await getOnboardingModel();
const modelMessages = await convertToModelMessages(body.messages);
const result = streamText({
model,
system: buildSystemPrompt(project.domain),
messages: modelMessages,
abortSignal: request.signal,
maxOutputTokens: 1600,
stopWhen: stepCountIs(4),
tools: {
generate_initial_strategy: tool({
description:
"Read the user's website, gather available SEO signals, and synthesize an onboarding SEO strategy to present to the user. Use when the user asks Sam to propose a strategy or analyze their site.",
inputSchema: z.object({}),
execute: async () => {
if (!project.domain) {
throw new AppError(
"VALIDATION_ERROR",
"Set a website domain first",
);
}
const seed = await runOnboardingSeed({
projectId: project.id,
billingCustomer: {
userId: context.userId,
userEmail: context.userEmail,
organizationId: context.organizationId,
projectId: project.id,
},
emailVerified: context.emailVerified,
domain: project.domain,
countryName: LOCATIONS[project.locationCode] ?? "your market",
locationCode: project.locationCode,
languageCode: project.languageCode,
});
if (seed.status === "complete") {
return { status: "complete", markdown: seed.markdown };
}
return {
status: "skipped",
note: "A strategy was already generated for this site during onboarding. Present the one shown earlier in this conversation, or suggest the user upgrade to keep refining it in the app.",
};
},
}),
},
});
return result.toUIMessageStreamResponse({
onError: () => "The assistant hit an error. Please try again.",
});
}
export const Route = createFileRoute("/api/onboarding/chat")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => {
try {
return await handleChat(request);
} catch (error) {
return responseForAppError(error, "Chat failed");
}
},
},
},
});

View File

@ -115,15 +115,17 @@ function VerifyEmailPage() {
const email = search.email ?? session?.user?.email; const email = search.email ?? session?.user?.email;
const isVerified = !!session?.user?.emailVerified; const isVerified = !!session?.user?.emailVerified;
const [isResending, setIsResending] = useState(false); const [isResending, setIsResending] = useState(false);
// A hosted user who still needs to verify (session resolved, not verified) // A hosted user who still needs to verify must see the resend / "check your
// must see the resend / "check your inbox" state — never a sign-in CTA, which // inbox" state — never a sign-in CTA, which the verification gate would
// the verification gate would immediately block (the email-verify trap). // immediately block (the email-verify trap). Keying off `email` (which the
// sign-up flow always passes) covers the just-signed-up case even while the
// session is still resolving, so we never flash the sign-in fallback.
const isWaiting = const isWaiting =
isHostedMode && isHostedMode &&
!errorMessage && !errorMessage &&
!bypassEmailVerification && !bypassEmailVerification &&
!isPending && !isVerified &&
!isVerified; (Boolean(email) || !isPending);
const pageCopy = getVerifyEmailPageCopy({ const pageCopy = getVerifyEmailPageCopy({
isHostedMode, isHostedMode,
errorMessage, errorMessage,

View File

@ -2,9 +2,15 @@ import {
createStartHandler, createStartHandler,
defaultStreamHandler, defaultStreamHandler,
} from "@tanstack/react-start/server"; } from "@tanstack/react-start/server";
import { routeAgentRequest } from "agents";
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards"; import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
import { customerHasPaidPlan } from "@/server/billing/subscription"; import {
customerHasPaidPlan,
getOrCreateOrganizationCustomer,
} from "@/server/billing/subscription";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
import { import {
@ -23,6 +29,51 @@ import {
const appFetch = createStartHandler(defaultStreamHandler); const appFetch = createStartHandler(defaultStreamHandler);
const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch); const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
// Authorize an onboarding-chat connection in the Worker, before it reaches the
// Durable Object. The DO instance name is the projectId (set client-side); we
// resolve the session here and confirm the caller's org owns that project, so
// the DO can trust its `name`. Returning a Response rejects; void lets it through.
async function authorizeOnboardingChat(
request: Request,
projectId: string,
): Promise<Response | undefined> {
let context;
try {
context = await resolveUserContextFromHeaders(request.headers);
} catch {
return new Response("Unauthorized", { status: 401 });
}
const project = await ProjectRepository.getProjectForOrganization(
projectId,
context.organizationId,
);
if (!project) {
return new Response("Forbidden", { status: 403 });
}
// Ensure the org's Autumn customer exists (and gets its default onboarding-plan
// credits) before the DO checks the balance — otherwise a brand-new org's first
// message can hit a false "out of credits" gate. Hosted-only; self-hosted has
// no Autumn.
if (await isHostedServerAuthMode()) {
await getOrCreateOrganizationCustomer(context);
}
return undefined;
}
// Route /agents/* to the onboarding chat DO. Auth happens here (both the WS
// upgrade and any HTTP message-history fetch), keeping it off the OAuth wrapper
// and TanStack route guard below.
async function routeOnboardingChatAgent(
request: Request,
env: Env,
): Promise<Response> {
const response = await routeAgentRequest(request, env, {
onBeforeConnect: (req, lobby) => authorizeOnboardingChat(req, lobby.name),
onBeforeRequest: (req, lobby) => authorizeOnboardingChat(req, lobby.name),
});
return response ?? new Response("Not found", { status: 404 });
}
function fetch( function fetch(
request: Request, request: Request,
env: Env, env: Env,
@ -32,6 +83,10 @@ function fetch(
const publicRequest = requestWithPublicOrigin(request); const publicRequest = requestWithPublicOrigin(request);
const pathname = new URL(publicRequest.url).pathname; const pathname = new URL(publicRequest.url).pathname;
if (pathname.startsWith("/agents/")) {
return routeOnboardingChatAgent(publicRequest, env);
}
if (isHostedAuthMode(authMode)) { if (isHostedAuthMode(authMode)) {
if (pathname === AUTUMN_WEBHOOK_PATH) { if (pathname === AUTUMN_WEBHOOK_PATH) {
return handleAutumnWebhookRequest(publicRequest); return handleAutumnWebhookRequest(publicRequest);
@ -57,6 +112,8 @@ function fetch(
// Export Workflow classes as named exports // Export Workflow classes as named exports
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow"; export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
export { RankCheckWorkflow } from "./server/workflows/RankCheckWorkflow"; export { RankCheckWorkflow } from "./server/workflows/RankCheckWorkflow";
// Durable Object class for the onboarding strategy chat (Agents SDK).
export { OnboardingChatAgent } from "./server/features/onboarding/OnboardingChatAgent";
export default { export default {
fetch, fetch,

View File

@ -19,6 +19,12 @@ vi.mock("@/server/lib/runtime-env", () => ({
isHostedServerAuthMode: vi.fn(), isHostedServerAuthMode: vi.fn(),
})); }));
// subscription.ts now imports posthog (for trackUsageCreditSpend); stub it so
// the test doesn't pull in the cloudflare:workers runtime it depends on.
vi.mock("@/server/lib/posthog", () => ({
captureServerEvent: vi.fn(),
}));
import { import {
customerHasPaidPlan, customerHasPaidPlan,
getOrCreateOrganizationCustomer, getOrCreateOrganizationCustomer,

View File

@ -2,8 +2,15 @@ import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
import { import {
AUTUMN_MANAGED_ACCESS_FEATURE_ID, AUTUMN_MANAGED_ACCESS_FEATURE_ID,
AUTUMN_PAID_PLAN_FEATURE_ID, AUTUMN_PAID_PLAN_FEATURE_ID,
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_CREDITS_PER_USD,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
SEO_DATA_COST_MARKUP,
roundUsdForBilling,
} from "@/shared/billing"; } from "@/shared/billing";
import type { CreditFeature } from "@/shared/billing-credit-features";
import { autumn } from "@/server/billing/autumn"; import { autumn } from "@/server/billing/autumn";
import { captureServerEvent } from "@/server/lib/posthog";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
export type BillingCustomerContext = Pick< export type BillingCustomerContext = Pick<
@ -48,3 +55,112 @@ export async function customerHasManagedAccess(customerId: string) {
return result.allowed; return result.allowed;
} }
// Remaining shared usage credits — the monthly `usage_credits` balance plus the
// rolled-over `topup_credits` balance. Both DataForSEO and LLM spend draw from
// these (the `seo_data_usage` and `llm_usage` features both map into them).
export async function getUsageCreditsRemaining(customerId: string): Promise<{
monthlyRemaining: number;
topupRemaining: number;
}> {
const [monthlyCheck, topupCheck] = await Promise.all([
autumn.check({ customerId, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID }),
autumn.check({
customerId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
}),
]);
return {
monthlyRemaining: monthlyCheck.balance?.remaining ?? 0,
topupRemaining: topupCheck.balance?.remaining ?? 0,
};
}
/**
* Throws INSUFFICIENT_CREDITS when the org has no usage/topup credits left.
* Returns the monthly remaining so a caller can split spend monthly-first.
*/
export async function assertUsageCreditsAvailable(
customerId: string,
): Promise<{ monthlyRemaining: number }> {
const { monthlyRemaining, topupRemaining } =
await getUsageCreditsRemaining(customerId);
if (monthlyRemaining + topupRemaining <= 0) {
throw new AppError("INSUFFICIENT_CREDITS");
}
return { monthlyRemaining };
}
/**
* Deducts a USD provider cost from the org's shared usage-credit pool: applies
* the platform markup, converts to credits, spends monthly `usage_credits`
* first then `topup_credits`, and emits the usage:credits_consume event. Both
* DataForSEO and onboarding-LLM spend route through here, so they draw from the
* one pool. Pass `monthlyRemaining` from the balance check that gated the call.
*/
export async function trackUsageCreditSpend(args: {
customer: BillingCustomerContext;
customerId: string;
creditFeature: CreditFeature;
costUsd: number;
monthlyRemaining: number;
properties?: Record<string, unknown>;
}): Promise<void> {
const totalCostUsd = roundUsdForBilling(args.costUsd * SEO_DATA_COST_MARKUP);
const totalCostCredits = Math.ceil(
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
);
if (totalCostCredits <= 0) return;
const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits);
const topupDeduct = totalCostCredits - monthlyDeduct;
const properties = {
currency: "USD",
creditFeature: args.creditFeature,
totalCostUsd,
totalCostCredits,
...args.properties,
};
if (monthlyDeduct > 0) {
await autumn.track({
customerId: args.customerId,
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: monthlyDeduct,
properties: {
...properties,
balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
},
});
}
if (topupDeduct > 0) {
await autumn.track({
customerId: args.customerId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
value: topupDeduct,
properties: {
...properties,
balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
},
});
}
await captureServerEvent({
distinctId: args.customer.userId,
event: "usage:credits_consume",
organizationId: args.customer.organizationId,
properties: {
project_id: args.customer.projectId,
credit_feature: args.creditFeature,
monthly_credits: monthlyDeduct,
topup_credits: topupDeduct,
total_credits: totalCostCredits,
cost_usd: totalCostUsd,
},
});
}

View File

@ -8,12 +8,11 @@ import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordM
import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage"; import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage";
import { getPagesPage } from "@/server/features/domain/services/domainPagesPage"; import { getPagesPage } from "@/server/features/domain/services/domainPagesPage";
// Lets a caller (e.g. the onboarding seed) attribute spend to its own feature // Lets a caller attribute spend to its own feature (e.g. onboarding). Applied
// and bypass the balance gate. Applied to the DataForSEO call, not the cache // to the DataForSEO call, not the cache key, so cached results are shared
// key, so cached results are shared across callers. // across callers.
type MeteringOverrides = { type MeteringOverrides = {
creditFeature?: CreditFeature; creditFeature?: CreditFeature;
skipBalanceAssert?: boolean;
}; };
/** Domain overview data is refreshed every 12 hours. */ /** Domain overview data is refreshed every 12 hours. */

View File

@ -0,0 +1,276 @@
import { AIChatAgent } from "@cloudflare/ai-chat";
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
stepCountIs,
streamText,
tool,
type StreamTextOnFinishCallback,
type ToolSet,
} from "ai";
import type { OnChatMessageOptions } from "@cloudflare/ai-chat";
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { readSite } from "@/server/features/onboarding/scrape";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { getOnboardingModel } from "@/server/lib/openrouter";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import {
customerHasManagedAccess,
getUsageCreditsRemaining,
trackUsageCreditSpend,
} from "@/server/billing/subscription";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import { isLabsLocationCode, LOCATIONS } from "@/shared/keyword-locations";
import openSeoFactSheet from "@/server/features/onboarding/openseo-fact-sheet.md?raw";
// OpenRouter (with usage accounting on) reports the real USD cost of each
// response under providerMetadata.openrouter.usage.cost.
const openRouterUsageSchema = z.object({
openrouter: z.object({ usage: z.object({ cost: z.number() }) }),
});
function openRouterCostUsd(providerMetadata: unknown): number {
const parsed = openRouterUsageSchema.safeParse(providerMetadata);
return parsed.success ? parsed.data.openrouter.usage.cost : 0;
}
function buildSystemPrompt(domain: string | null): string {
return [
"You are Sam, the SEO onboarding agent inside OpenSEO. Introduce yourself as Sam if the user asks who you are.",
"Answer SEO questions concisely and practically.",
"Only answer questions related to SEO, OpenSEO, OpenSEO setup, MCP/AI-agent SEO workflows, Google Search Console in OpenSEO, or open-source/self-hosting topics. If the user asks about anything else, politely say you're here to help them get up and running with OpenSEO and ask what they want to know about OpenSEO or SEO.",
"For OpenSEO product questions, use the OpenSEO Fact Sheet below as your source of truth. Do not invent product facts, feature details, pricing, limits, integrations, or support claims. If the fact sheet does not support the answer, say you are not sure and suggest contacting ben@openseo.so.",
"When users want advice from people in the community, a second opinion, or help beyond this onboarding chat, mention the OpenSEO Discord from the fact sheet.",
"When the user asks how OpenSEO helps them get traffic or rank higher, lead with the fact sheet's SEO strategy framing: positioning, topical authority, focused early topics, then expansion into broader searches. Do not answer as only a feature list.",
"OpenSEO is limited until the user upgrades to the paid plan. Be direct about that, but do not hard-sell.",
"You have tools to research the user's own site: read_website reads their pages as text, and get_seo_metrics returns their estimated organic traffic, ranking-keyword count, and the keywords they already rank for. Use them whenever the user asks you to analyze their site, recommend an SEO strategy, or for any site-specific advice. read_website is always available; get_seo_metrics may report it's unavailable for brand-new sites or unsupported markets — if so, work from the site content and say rankings aren't available yet. Never invent metrics you weren't given by a tool.",
"When the user asks for a strategy, recommendations, or an analysis of their site, first gather data with the tools, then write a concise, practical, honest strategy specific to THIS site (never generic) in Markdown with these sections: '## Positioning' (one paragraph on what the site does and how it should position itself in search); '## Themes' (3-5 content/topic themes worth owning, each a bullet with a one-line rationale); '## Target keywords' (a short Markdown table of starter keywords with columns Keyword | Why it fits — prefer and mark keywords they already rank for; if the site is brand new with no rankings, say so plainly and propose keywords from the content); '## Do this next' (a numbered list of 3-5 concrete next actions). Keep the whole strategy under ~400 words.",
domain
? `The user's website is ${domain}.`
: "If you need the user's website before answering, ask for it briefly.",
`OpenSEO Fact Sheet:\n\n${openSeoFactSheet}`,
].join("\n\n");
}
// A non-LLM assistant turn streamed back over the chat protocol. Used to surface
// billing gates ("Subscribe to continue") without spending an LLM call — the
// client renders it as a normal message from Sam.
function staticAssistantResponse(text: string): Response {
const stream = createUIMessageStream({
execute: ({ writer }) => {
const id = crypto.randomUUID();
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: text });
writer.write({ type: "text-end", id });
},
});
return createUIMessageStreamResponse({ stream });
}
/**
* Durable Object backing the onboarding strategy chat. The conversation is
* persisted automatically in the DO's SQLite (`this.messages`), so it survives
* reloads. One instance per project: the DO instance name IS the projectId, set
* by the client (`useAgent({ name: projectId })`) and authorized in the Worker
* (`onBeforeConnect`) before any connection reaches here so the DO trusts that
* its caller may act on `this.name` and derives the org/domain from the project.
*/
export class OnboardingChatAgent extends AIChatAgent {
// Cap stored history; the onboarding chat is short and pre-paywall.
maxPersistedMessages = 60;
async onChatMessage(
onFinish: StreamTextOnFinishCallback<ToolSet>,
options?: OnChatMessageOptions,
): Promise<Response | undefined> {
const project = await ProjectRepository.getProjectById(this.name);
if (!project) {
return staticAssistantResponse(
"I couldn't find your project. Please refresh and try again.",
);
}
const { organizationId } = project;
const billingCustomer = {
// The org is the Autumn customer; userId is only an analytics distinctId.
userId: organizationId,
userEmail: "",
organizationId,
projectId: project.id,
};
const metering = { creditFeature: "onboarding" as const };
// In hosted mode, gate every turn on billing: past the free-question cap the
// user must have paid access, and either way the org must still have credits
// — LLM tokens and DataForSEO tool calls all draw down the same
// onboarding-plan balance. Self-hosted has no Autumn balance and brings its
// own provider keys, so it's ungated. Captured for metering in onFinish.
let creditCustomerId: string | null = null;
let monthlyCreditsRemaining = 0;
if (await isHostedServerAuthMode()) {
const questionCount = this.messages.filter(
(message) => message.role === "user",
).length;
if (
questionCount > FREE_ONBOARDING_QUESTION_LIMIT &&
!(await customerHasManagedAccess(organizationId))
) {
return staticAssistantResponse(
"You've used all your free strategy questions. Subscribe to continue.",
);
}
const { monthlyRemaining, topupRemaining } =
await getUsageCreditsRemaining(organizationId);
if (monthlyRemaining + topupRemaining <= 0) {
return staticAssistantResponse(
"You've used your onboarding credits. Subscribe to continue.",
);
}
creditCustomerId = organizationId;
monthlyCreditsRemaining = monthlyRemaining;
}
const model = await getOnboardingModel();
// `tools` is widened to ToolSet so streamText infers a generic tool set;
// that makes its onFinish event assignable to the
// StreamTextOnFinishCallback<ToolSet> we forward for message persistence.
const result = streamText({
model,
system: buildSystemPrompt(project.domain),
messages: await convertToModelMessages(this.messages),
// Cancel the (billable) LLM call if the user aborts/navigates away.
abortSignal: options?.abortSignal,
maxOutputTokens: 1600,
stopWhen: stepCountIs(5),
// Meter LLM spend against the same credit pool as DataForSEO: sum the real
// per-step cost OpenRouter reports and deduct it. Best-effort, hosted-only.
onFinish: async (event) => {
if (creditCustomerId !== null) {
const costUsd = event.steps.reduce(
(sum, step) => sum + openRouterCostUsd(step.providerMetadata),
0,
);
await trackUsageCreditSpend({
customer: billingCustomer,
customerId: creditCustomerId,
creditFeature: "onboarding",
costUsd,
monthlyRemaining: monthlyCreditsRemaining,
properties: { provider: "openrouter" },
});
}
// Persist the assistant turn to this.messages (DO SQLite).
await onFinish(event);
},
tools: {
read_website: tool({
description:
"Read the user's own website (their pages, as plain text) to ground site-specific advice and strategy. Uses the project's saved domain.",
inputSchema: z.object({}),
execute: async () => {
if (!project.domain) {
throw new AppError(
"VALIDATION_ERROR",
"Set a website domain first",
);
}
const site = await readSite(project.domain);
if (site.blocked) {
return {
blocked: true,
pages: [],
note: "Could not read the site's pages. Ask the user to describe what they do, and keep the advice high-level.",
};
}
return {
blocked: false,
pages: site.pages.map((page) => ({
url: page.url,
title: page.title,
text: page.text,
})),
};
},
}),
get_seo_metrics: tool({
description:
"Get search-data signal for the user's own site: estimated organic traffic, number of ranking keywords, and the keywords they already rank for (top by traffic). Use to ground strategy in real rankings. May report unavailable for brand-new sites or unsupported markets.",
inputSchema: z.object({}),
execute: async () => {
if (!project.domain) {
throw new AppError(
"VALIDATION_ERROR",
"Set a website domain first",
);
}
// Domain endpoints are Labs-only, so an unsupported market gets
// content-only advice. Spend is bounded by the org's credit balance,
// already asserted for the turn.
if (!isLabsLocationCode(project.locationCode)) {
return {
available: false,
reason:
"Ranking data isn't available for this market yet. Work from the site content instead.",
};
}
// Fetch the overview and ranked keywords in parallel so the tool
// doesn't block on the two DataForSEO calls in series. Trade-off:
// this always issues the (metered) ranked-keywords call, even for
// sites with no rankings where the sequential version skipped it.
const [overview, ranked] = await Promise.all([
DomainService.getOverview(
{
projectId: project.id,
domain: project.domain,
includeSubdomains: false,
locationCode: project.locationCode,
languageCode: project.languageCode,
},
billingCustomer,
metering,
),
DomainService.getSuggestedKeywords(
{
domain: project.domain,
locationCode: project.locationCode,
languageCode: project.languageCode,
organizationId,
projectId: project.id,
},
billingCustomer,
metering,
),
]);
const rankedKeywords = overview.hasData
? ranked.slice(0, 20).map((kw) => ({
keyword: kw.keyword,
position: kw.position,
searchVolume: kw.searchVolume,
keywordDifficulty: kw.keywordDifficulty,
}))
: [];
return {
available: true,
market: LOCATIONS[project.locationCode] ?? "your market",
hasRankings: overview.hasData,
organicTraffic: overview.organicTraffic,
organicKeywords: overview.organicKeywords,
rankedKeywords,
};
},
}),
} as ToolSet,
});
return result.toUIMessageStreamResponse({
onError: () => "The assistant hit an error. Please try again.",
});
}
}

View File

@ -12,7 +12,7 @@ const FETCH_TIMEOUT_MS = 10_000;
const MAX_RESPONSE_BYTES = 2_000_000; const MAX_RESPONSE_BYTES = 2_000_000;
const USER_AGENT = "OpenSEO-Onboarding/1.0 (+https://openseo.so)"; const USER_AGENT = "OpenSEO-Onboarding/1.0 (+https://openseo.so)";
export type ScrapedPage = { type ScrapedPage = {
url: string; url: string;
title: string | null; title: string | null;
text: string; text: string;

View File

@ -1,144 +0,0 @@
import { and, eq, isNull, or } from "drizzle-orm";
import { db } from "@/db";
import { projects } from "@/db/schema";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { isLabsLocationCode } from "@/shared/keyword-locations";
import { readSite } from "@/server/features/onboarding/scrape";
import {
synthesizeStrategy,
type RankedKeyword,
} from "@/server/features/onboarding/synthesis";
type OnboardingSeedResult =
| { status: "complete"; markdown: string }
| { status: "skipped" };
type OnboardingSeedInput = {
projectId: string;
billingCustomer: BillingCustomerContext;
emailVerified: boolean;
domain: string;
countryName: string;
locationCode: number;
languageCode: string;
};
// Atomic claim: one free strategy run per project. Claims only when no run has
// succeeded yet (status null) or a prior run failed (retry). A 'complete' run is
// NOT re-claimable — this is a free, balance-gate-bypassed run, so we don't let
// it be looped to amplify spend; regenerating later is a separate paid action.
// A concurrent 'running' run is also blocked.
async function claimRun(projectId: string): Promise<boolean> {
const updated = await db
.update(projects)
.set({
onboardingRunStatus: "running",
onboardingRunAt: new Date().toISOString(),
})
.where(
and(
eq(projects.id, projectId),
or(
isNull(projects.onboardingRunStatus),
eq(projects.onboardingRunStatus, "failed"),
),
),
)
.returning({ id: projects.id });
return updated.length > 0;
}
async function setRunStatus(projectId: string, status: "complete" | "failed") {
await db
.update(projects)
.set({ onboardingRunStatus: status })
.where(eq(projects.id, projectId));
}
/**
* Runs the onboarding strategy generation for a project: read the site, pull
* (paid) ranking signal, and synthesize a strategy, returned to the chat (not
* persisted persistence is deferred to a later PR). Paid DataForSEO calls are
* attributed to the 'onboarding' feature and skip the balance gate so a
* zero-balance new signup still completes; the underlying services are
* cache-first, so a re-run rarely re-spends.
*/
export async function runOnboardingSeed(
input: OnboardingSeedInput,
): Promise<OnboardingSeedResult> {
const claimed = await claimRun(input.projectId);
if (!claimed) {
return { status: "skipped" };
}
try {
const site = await readSite(input.domain);
let organicTraffic: number | null = null;
let organicKeywords: number | null = null;
let rankedKeywords: RankedKeyword[] = [];
// Free stages (read) run for everyone; paid DataForSEO signal is gated on a
// verified email (abuse surface) and a Labs-supported location — the domain
// endpoints used here are Labs-only, so a Google-Ads-only country gets a
// content-only strategy rather than a failed run.
if (input.emailVerified && isLabsLocationCode(input.locationCode)) {
const metering = {
creditFeature: "onboarding" as const,
skipBalanceAssert: true,
};
const overview = await DomainService.getOverview(
{
projectId: input.projectId,
domain: input.domain,
includeSubdomains: false,
locationCode: input.locationCode,
languageCode: input.languageCode,
},
input.billingCustomer,
metering,
);
organicTraffic = overview.organicTraffic;
organicKeywords = overview.organicKeywords;
if (overview.hasData) {
const ranked = await DomainService.getSuggestedKeywords(
{
domain: input.domain,
locationCode: input.locationCode,
languageCode: input.languageCode,
organizationId: input.billingCustomer.organizationId,
projectId: input.projectId,
},
input.billingCustomer,
metering,
);
rankedKeywords = ranked.map((kw) => ({
keyword: kw.keyword,
position: kw.position,
searchVolume: kw.searchVolume,
keywordDifficulty: kw.keywordDifficulty,
}));
}
}
const markdown = await synthesizeStrategy({
domain: input.domain,
countryName: input.countryName,
pages: site.pages,
scrapeBlocked: site.blocked,
organicTraffic,
organicKeywords,
rankedKeywords,
});
await setRunStatus(input.projectId, "complete");
return { status: "complete", markdown };
} catch (error) {
// Reset to a re-runnable state so the user can retry (cache-backed, cheap).
await setRunStatus(input.projectId, "failed");
throw error;
}
}

View File

@ -1,91 +0,0 @@
import { generateText } from "ai";
import { getOnboardingModel } from "@/server/lib/openrouter";
import type { ScrapedPage } from "@/server/features/onboarding/scrape";
export type RankedKeyword = {
keyword: string;
position: number | null;
searchVolume: number | null;
keywordDifficulty: number | null;
};
type StrategyInput = {
domain: string;
countryName: string;
pages: ScrapedPage[];
scrapeBlocked: boolean;
organicTraffic: number | null;
organicKeywords: number | null;
rankedKeywords: RankedKeyword[];
};
const SYSTEM_PROMPT = `You are an SEO strategist onboarding a new user to OpenSEO.
From what you can see of their website plus the data provided, write a concise,
practical, and honest first SEO strategy in Markdown. Be specific to THIS site
never generic. Structure it as:
## Positioning
One paragraph on what the site does and how it should position itself in search.
## Themes
3-5 content/topic themes worth owning, each a bullet with a one-line rationale.
## Target keywords
A short markdown table of starter keywords (Keyword | Why it fits). If ranking
data is provided, prefer and mark keywords they already rank for. If the site is
brand new with no rankings, say so plainly and propose keywords from the content.
## Do this next
A numbered list of 3-5 concrete next actions.
Keep it under ~400 words. Do not invent metrics you weren't given.`;
function buildPrompt(input: StrategyInput): string {
const lines: string[] = [];
lines.push(`Domain: ${input.domain}`);
lines.push(`Primary market: ${input.countryName}`);
lines.push(
`Organic traffic estimate: ${input.organicTraffic ?? "unknown"}; ` +
`ranking keywords: ${input.organicKeywords ?? "unknown"}.`,
);
if (input.rankedKeywords.length > 0) {
lines.push("\nKeywords the site already ranks for (top by traffic):");
for (const kw of input.rankedKeywords.slice(0, 20)) {
lines.push(
`- ${kw.keyword} (pos ${kw.position ?? "?"}, vol ${kw.searchVolume ?? "?"}, KD ${kw.keywordDifficulty ?? "?"})`,
);
}
} else {
lines.push(
"\nThe site has no meaningful organic rankings yet (treat as brand new).",
);
}
if (input.scrapeBlocked) {
lines.push(
"\nWe could not read the site's pages. Ask the user to describe what they do, and keep the strategy high-level.",
);
} else {
lines.push("\nPages read from the site:");
for (const page of input.pages) {
lines.push(`\n### ${page.title ?? page.url} (${page.url})`);
lines.push(page.text);
}
}
return lines.join("\n");
}
/** Generates the initial onboarding strategy markdown for a site. */
export async function synthesizeStrategy(
input: StrategyInput,
): Promise<string> {
const model = await getOnboardingModel();
const { text } = await generateText({
model,
system: SYSTEM_PROMPT,
prompt: buildPrompt(input),
});
return text.trim();
}

View File

@ -44,6 +44,19 @@ async function getProjectForOrganization(
return project ?? null; return project ?? null;
} }
// Look up a project by id alone (no org scoping). Only for trusted server
// contexts that have already authorized access another way — e.g. the
// onboarding chat Durable Object, whose connections are authorized in the
// Worker before they reach the DO, and which derives its org from the project.
async function getProjectById(projectId: string) {
const [project] = await db
.select()
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.archivedAt)))
.limit(1);
return project ?? null;
}
async function createProject( async function createProject(
organizationId: string, organizationId: string,
name: string, name: string,
@ -146,6 +159,7 @@ export const ProjectRepository = {
listArchivedProjects, listArchivedProjects,
countProjects, countProjects,
getProjectForOrganization, getProjectForOrganization,
getProjectById,
createProject, createProject,
updateProject, updateProject,
tryCreateDefaultProject, tryCreateDefaultProject,

View File

@ -33,9 +33,16 @@ vi.mock("@/server/billing/autumn", () => ({
}, },
})); }));
vi.mock("@/server/billing/subscription", () => ({ // Keep the real subscription module (its assertUsageCreditsAvailable calls the
// mocked autumn.check) and only stub the customer lookup, so the balance-assert
// logic stays exercised through these tests after it moved out of client.ts.
vi.mock("@/server/billing/subscription", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
getOrCreateOrganizationCustomer: getOrCreateMock, getOrCreateOrganizationCustomer: getOrCreateMock,
})); };
});
vi.mock("@/server/lib/runtime-env", () => ({ vi.mock("@/server/lib/runtime-env", () => ({
isHostedServerAuthMode: isHostedServerAuthModeMock, isHostedServerAuthMode: isHostedServerAuthModeMock,

View File

@ -1,16 +1,12 @@
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_CREDITS_PER_USD,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
SEO_DATA_COST_MARKUP,
roundUsdForBilling,
} from "@/shared/billing";
import { import {
type CreditFeature, type CreditFeature,
mapDataforseoPathToCreditFeature, mapDataforseoPathToCreditFeature,
} from "@/shared/billing-credit-features"; } from "@/shared/billing-credit-features";
import { autumn } from "@/server/billing/autumn"; import {
import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription"; assertUsageCreditsAvailable,
getOrCreateOrganizationCustomer,
trackUsageCreditSpend,
} from "@/server/billing/subscription";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { import {
fetchBusinessListingsSearch, fetchBusinessListingsSearch,
@ -56,8 +52,6 @@ import {
type DataforseoApiCallCost, type DataforseoApiCallCost,
type DataforseoApiResponse, type DataforseoApiResponse,
} from "@/server/lib/dataforseo/envelope"; } from "@/server/lib/dataforseo/envelope";
import { AppError } from "@/server/lib/errors";
import { captureServerEvent } from "@/server/lib/posthog";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
export { mapDataforseoPathToCreditFeature }; export { mapDataforseoPathToCreditFeature };
@ -69,26 +63,19 @@ export { mapDataforseoPathToCreditFeature };
* *
* `defaultFeature` is the fallback credit feature; a caller can override it per * `defaultFeature` is the fallback credit feature; a caller can override it per
* call by passing `creditFeature` in the input (e.g. an MCP tool attributing * call by passing `creditFeature` in the input (e.g. an MCP tool attributing
* spend to its own feature). The extra fields are ignored by the fetchers, which * spend to its own feature). The extra field is ignored by the fetchers, which
* read named fields rather than spreading the input. * read named fields rather than spreading the input.
*
* `skipBalanceAssert` (in the input) lets a caller spend without first gating on
* the org's remaining balance used by the free onboarding seed so a
* zero-balance new signup still gets its strategy. Cost is still tracked.
*/ */
function meter<I, T>( function meter<I, T>(
customer: BillingCustomerContext, customer: BillingCustomerContext,
fetcher: (input: I) => Promise<DataforseoApiResponse<T>>, fetcher: (input: I) => Promise<DataforseoApiResponse<T>>,
defaultFeature?: CreditFeature, defaultFeature?: CreditFeature,
): ( ): (input: I & { creditFeature?: CreditFeature }) => Promise<T> {
input: I & { creditFeature?: CreditFeature; skipBalanceAssert?: boolean },
) => Promise<T> {
return (input) => return (input) =>
meterDataforseoCall( meterDataforseoCall(
customer, customer,
() => fetcher(input), () => fetcher(input),
input.creditFeature ?? defaultFeature, input.creditFeature ?? defaultFeature,
input.skipBalanceAssert ?? false,
); );
} }
@ -155,7 +142,6 @@ async function meterDataforseoCall<T>(
customer: BillingCustomerContext, customer: BillingCustomerContext,
execute: () => Promise<DataforseoApiResponse<T>>, execute: () => Promise<DataforseoApiResponse<T>>,
creditFeature?: CreditFeature, creditFeature?: CreditFeature,
skipBalanceAssert = false,
): Promise<T> { ): Promise<T> {
const isHostedMode = await isHostedServerAuthMode(); const isHostedMode = await isHostedServerAuthMode();
@ -166,11 +152,9 @@ async function meterDataforseoCall<T>(
const billingCustomer = await getOrCreateOrganizationCustomer(customer); const billingCustomer = await getOrCreateOrganizationCustomer(customer);
// The onboarding seed skips the balance gate so a zero-balance new signup const { monthlyRemaining } = await assertUsageCreditsAvailable(
// still gets a strategy; spend is tracked against monthly balance below. billingCustomer.id,
const { monthlyRemaining } = skipBalanceAssert );
? { monthlyRemaining: 0 }
: await assertSeoDataBalanceAvailable(billingCustomer.id);
let result: DataforseoApiResponse<T>; let result: DataforseoApiResponse<T>;
try { try {
@ -199,28 +183,6 @@ async function meterDataforseoCall<T>(
return result.data; return result.data;
} }
async function assertSeoDataBalanceAvailable(customerId: string) {
const [monthlyCheck, topupCheck] = await Promise.all([
autumn.check({
customerId,
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
}),
autumn.check({
customerId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
}),
]);
const monthlyRemaining = monthlyCheck.balance?.remaining ?? 0;
const topupRemaining = topupCheck.balance?.remaining ?? 0;
if (monthlyRemaining + topupRemaining <= 0) {
throw new AppError("INSUFFICIENT_CREDITS");
}
return { monthlyRemaining };
}
async function trackDataforseoCost(args: { async function trackDataforseoCost(args: {
customer: BillingCustomerContext; customer: BillingCustomerContext;
customerId: string; customerId: string;
@ -228,66 +190,17 @@ async function trackDataforseoCost(args: {
monthlyRemaining: number; monthlyRemaining: number;
creditFeature?: CreditFeature; creditFeature?: CreditFeature;
}) { }) {
const totalCostUsd = roundUsdForBilling( await trackUsageCreditSpend({
args.billing.costUsd * SEO_DATA_COST_MARKUP, customer: args.customer,
); customerId: args.customerId,
const totalCostCredits = Math.ceil( creditFeature:
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD, args.creditFeature ?? mapDataforseoPathToCreditFeature(args.billing.path),
); costUsd: args.billing.costUsd,
monthlyRemaining: args.monthlyRemaining,
const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits); properties: {
const topupDeduct = totalCostCredits - monthlyDeduct;
const creditFeature =
args.creditFeature ?? mapDataforseoPathToCreditFeature(args.billing.path);
const properties = {
provider: "dataforseo", provider: "dataforseo",
currency: "USD",
paths: [args.billing.path.join("/")], paths: [args.billing.path.join("/")],
creditFeature,
totalCostUsd,
totalCostCredits,
fromCache: false, fromCache: false,
};
if (monthlyDeduct > 0) {
await autumn.track({
customerId: args.customerId,
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: monthlyDeduct,
properties: {
...properties,
balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
}, },
}); });
}
if (topupDeduct > 0) {
await autumn.track({
customerId: args.customerId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
value: topupDeduct,
properties: {
...properties,
balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
},
});
}
if (totalCostCredits > 0) {
await captureServerEvent({
distinctId: args.customer.userId,
event: "usage:credits_consume",
organizationId: args.customer.organizationId,
properties: {
project_id: args.customer.projectId,
credit_feature: creditFeature,
monthly_credits: monthlyDeduct,
topup_credits: topupDeduct,
total_credits: totalCostCredits,
cost_usd: totalCostUsd,
},
});
}
} }

View File

@ -1,18 +1,25 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import {
import type { LanguageModel } from "ai"; createOpenRouter,
type LanguageModelV3,
} from "@openrouter/ai-sdk-provider";
import { import {
getOptionalEnvValue, getOptionalEnvValue,
getRequiredEnvValue, getRequiredEnvValue,
} from "@/server/lib/runtime-env"; } from "@/server/lib/runtime-env";
// OpenRouter model slug used for onboarding strategy synthesis + chat. Override // OpenRouter model slug used for the onboarding chat. Override
// with OPENROUTER_MODEL to swap models without a code change. // with OPENROUTER_MODEL to swap models without a code change.
const DEFAULT_ONBOARDING_MODEL = "anthropic/claude-sonnet-4.6"; const DEFAULT_ONBOARDING_MODEL = "anthropic/claude-sonnet-4.6";
/** Returns the AI SDK LanguageModel for onboarding, wired through OpenRouter. */ /**
export async function getOnboardingModel(): Promise<LanguageModel> { * Returns the AI SDK LanguageModel for onboarding. `usage: { include: true }`
* turns on OpenRouter usage accounting so each response carries its real USD
* cost (providerMetadata.openrouter.usage.cost) which we meter against the
* shared usage-credit pool.
*/
export async function getOnboardingModel(): Promise<LanguageModelV3> {
const apiKey = await getRequiredEnvValue("OPENROUTER_API_KEY"); const apiKey = await getRequiredEnvValue("OPENROUTER_API_KEY");
const model = const modelId =
(await getOptionalEnvValue("OPENROUTER_MODEL")) ?? DEFAULT_ONBOARDING_MODEL; (await getOptionalEnvValue("OPENROUTER_MODEL")) ?? DEFAULT_ONBOARDING_MODEL;
return createOpenRouter({ apiKey })(model); return createOpenRouter({ apiKey })(modelId, { usage: { include: true } });
} }

View File

@ -15,7 +15,7 @@ import {
// Returns the onboarding project (id + domain). Uses the org's default project; // Returns the onboarding project (id + domain). Uses the org's default project;
// onboarding targets a single project in v1. // onboarding targets a single project in v1.
export const getOnboardingStrategyState = createServerFn({ method: "GET" }) export const getOnboardingChatState = createServerFn({ method: "GET" })
.middleware(requireAuthenticatedContext) .middleware(requireAuthenticatedContext)
.handler(async ({ context }) => { .handler(async ({ context }) => {
const [project] = await ProjectService.listProjectsEnsuringOne( const [project] = await ProjectService.listProjectsEnsuringOne(
@ -36,7 +36,7 @@ const saveSiteSchema = z.object({
locationCode: z.number().int(), locationCode: z.number().int(),
}); });
// Persists the site + default location for the project before generation. // Persists the site + default location for the onboarding project.
export const saveOnboardingSite = createServerFn({ method: "POST" }) export const saveOnboardingSite = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext) .middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => saveSiteSchema.parse(data)) .inputValidator((data: unknown) => saveSiteSchema.parse(data))
@ -53,19 +53,12 @@ export const saveOnboardingSite = createServerFn({ method: "POST" })
throw new AppError("VALIDATION_ERROR", "Unsupported location"); throw new AppError("VALIDATION_ERROR", "Unsupported location");
} }
const newDomain = normalizeDomainInput(data.domain, false); const newDomain = normalizeDomainInput(data.domain, false);
const domainChanged = newDomain !== project.domain;
await db await db
.update(projects) .update(projects)
.set({ .set({
domain: newDomain, domain: newDomain,
locationCode: data.locationCode, locationCode: data.locationCode,
languageCode: getLanguageCode(data.locationCode), languageCode: getLanguageCode(data.locationCode),
// A new domain is a different site, so reset the one-free-run claim
// guard; otherwise a 'complete' status blocks the free seed for the
// corrected domain. Same-domain edits keep the guard intact.
...(domainChanged
? { onboardingRunStatus: null, onboardingRunAt: null }
: {}),
}) })
.where( .where(
and( and(

View File

@ -7,6 +7,8 @@ export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan";
// Granted by both the legacy free plan (grandfathered users) and the paid // Granted by both the legacy free plan (grandfathered users) and the paid
// base plan. New customers get no default product, so this is the paywall. // base plan. New customers get no default product, so this is the paywall.
export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access"; export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access";
// The shared usage-credit pool. Both DataForSEO and onboarding-LLM spend deduct
// from these (monthly usage_credits first, then rolled-over topup_credits).
export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits"; export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits";
export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits"; export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits";
export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000; export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;

View File

@ -1,5 +1,8 @@
// Free-plan users get a bounded number of strategy-refinement questions in the // Free-plan users get a bounded number of strategy-refinement questions in the
// onboarding chat before they're asked to subscribe. Shared so the client gate // onboarding chat before they're nudged to subscribe. Shared so the client gate
// (disables the composer, shows "N left") and the server backstop (rejects an // (disables the composer, shows "N left") and the server-side re-check agree on
// over-limit request that bypassed the client) agree on the same number. // the same number. NOTE: the server counts client-supplied chat history, so a
// crafted client can keep the count low — this cap is a conversion nudge, not a
// security boundary. The real spend bound is the org's credit balance (asserted
// server-side on every turn and inside each DataForSEO call).
export const FREE_ONBOARDING_QUESTION_LIMIT = 7; export const FREE_ONBOARDING_QUESTION_LIMIT = 7;

View File

@ -26,6 +26,23 @@
"class_name": "RankCheckWorkflow", "class_name": "RankCheckWorkflow",
}, },
], ],
// Durable Object backing the onboarding strategy chat (Agents SDK
// AIChatAgent). One instance per project; messages persist in the DO's
// SQLite. SQLite-backed classes must be declared in `migrations` below.
"durable_objects": {
"bindings": [
{
"name": "ONBOARDING_CHAT",
"class_name": "OnboardingChatAgent",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["OnboardingChatAgent"],
},
],
"triggers": { "triggers": {
"crons": ["*/15 * * * *"], "crons": ["*/15 * * * *"],
}, },