Add onboarding chat helper
This commit is contained in:
parent
dec7fe68dc
commit
e57768a8ce
4
drizzle/0024_clumsy_barracuda.sql
Normal file
4
drizzle/0024_clumsy_barracuda.sql
Normal file
@ -0,0 +1,4 @@
|
||||
ALTER TABLE `projects` ADD `location_code` integer DEFAULT 2840 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `language_code` text DEFAULT 'en' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `onboarding_run_status` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `onboarding_run_at` text;
|
||||
2648
drizzle/meta/0024_snapshot.json
Normal file
2648
drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -169,6 +169,13 @@
|
||||
"when": 1781467831570,
|
||||
"tag": "0023_sad_cobalt_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1781809245835,
|
||||
"tag": "0024_clumsy_barracuda",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -58,9 +58,11 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.201",
|
||||
"@cloudflare/workers-oauth-provider": "^0.4.0",
|
||||
"@every-app/sdk": "^0.1.14",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@openrouter/ai-sdk-provider": "^2.9.0",
|
||||
"@tanstack/query-core": "^5.90.9",
|
||||
"@tanstack/react-form": "^1.25.0",
|
||||
"@tanstack/react-query": "^5.90.9",
|
||||
@ -69,6 +71,7 @@
|
||||
"@tanstack/react-start": "^1.167.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"agents": "0.12.3",
|
||||
"ai": "^6.0.199",
|
||||
"autumn-js": "^1.1.7",
|
||||
"better-auth": "^1.5.5",
|
||||
"cheerio": "^1.2.0",
|
||||
|
||||
82
pnpm-lock.yaml
generated
82
pnpm-lock.yaml
generated
@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@ai-sdk/react':
|
||||
specifier: ^3.0.201
|
||||
version: 3.0.201(react@19.2.4)(zod@4.3.6)
|
||||
'@cloudflare/workers-oauth-provider':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
@ -17,6 +20,9 @@ importers:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: 1.29.0
|
||||
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
|
||||
'@openrouter/ai-sdk-provider':
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6)
|
||||
'@tanstack/query-core':
|
||||
specifier: ^5.90.9
|
||||
version: 5.90.20
|
||||
@ -40,7 +46,10 @@ importers:
|
||||
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
agents:
|
||||
specifier: 0.12.3
|
||||
version: 0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.177(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6)
|
||||
version: 0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.199(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6)
|
||||
ai:
|
||||
specifier: ^6.0.199
|
||||
version: 6.0.199(zod@4.3.6)
|
||||
autumn-js:
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4)
|
||||
@ -189,8 +198,8 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@ai-sdk/gateway@3.0.112':
|
||||
resolution: {integrity: sha512-jiBao9pR4owWyjo0BnuNc7WSQBGOD0thysE4AFgZXaG+zMFbISQXUkJr7ePw/phBvePy7jE5FSA2Lf7lwqUiiQ==}
|
||||
'@ai-sdk/gateway@3.0.127':
|
||||
resolution: {integrity: sha512-Obmw5hmE5x+ccRrMp/Djx5r0rpFVX87YqE6OY06g5fwYlRI30dA84ARfTzX45ivCvkW4eCnBpOVXVWQ/pjH85w==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
@ -205,6 +214,12 @@ packages:
|
||||
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/react@3.0.201':
|
||||
resolution: {integrity: sha512-sDYdkPB96aHPrF5WKP0BdjRjymgZOdIMzNt1SlYPCoWWvLnV+UMwnIqvqEG+OAa9n4Sq7J3A3N/JD7rozow+CA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@ -1268,14 +1283,17 @@ packages:
|
||||
resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==}
|
||||
engines: {node: '>=20.0'}
|
||||
|
||||
'@openrouter/ai-sdk-provider@2.9.0':
|
||||
resolution: {integrity: sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
ai: ^6.0.0
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.0':
|
||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@ -2476,8 +2494,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
ai@6.0.177:
|
||||
resolution: {integrity: sha512-1xQtbeWwNcLyyM86ixZhkKvT+WRXc1lvarIKqPVtsyn8F9NDikwUMBqYu+aQKDgMht50SMXh4qboYuU8MeHZZA==}
|
||||
ai@6.0.199:
|
||||
resolution: {integrity: sha512-6H9RPEjzBQECM+eU1JxAh6jHcZPU/6q5QZ8D8QV8agubf0Mm/kcBlwqrFcFtup6RQzmEvMkVaQOoLCZ8bQ13lA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
@ -4287,6 +4305,11 @@ packages:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
swr@2.4.1:
|
||||
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
tailwindcss@4.2.1:
|
||||
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
|
||||
|
||||
@ -4294,6 +4317,10 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
throttleit@2.1.0:
|
||||
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
@ -4683,7 +4710,7 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ai-sdk/gateway@3.0.112(zod@4.3.6)':
|
||||
'@ai-sdk/gateway@3.0.127(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.10
|
||||
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
|
||||
@ -4701,6 +4728,16 @@ snapshots:
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/react@3.0.201(react@19.2.4)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
|
||||
ai: 6.0.199(zod@4.3.6)
|
||||
react: 19.2.4
|
||||
swr: 2.4.1(react@19.2.4)
|
||||
throttleit: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@ -5532,12 +5569,15 @@ snapshots:
|
||||
|
||||
'@oozcitak/util@10.0.0': {}
|
||||
|
||||
'@openrouter/ai-sdk-provider@2.9.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6)':
|
||||
dependencies:
|
||||
ai: 6.0.199(zod@4.3.6)
|
||||
zod: 4.3.6
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/api@1.9.0': {}
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
@ -6554,13 +6594,13 @@ snapshots:
|
||||
dependencies:
|
||||
humanize-ms: 1.2.1
|
||||
|
||||
agents@0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.177(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6):
|
||||
agents@0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.199(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6):
|
||||
dependencies:
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
'@cfworker/json-schema': 4.1.1
|
||||
'@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
|
||||
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
||||
ai: 6.0.177(zod@4.3.6)
|
||||
ai: 6.0.199(zod@4.3.6)
|
||||
cron-schedule: 6.0.0
|
||||
mimetext: 3.0.28
|
||||
nanoid: 5.1.11
|
||||
@ -6579,12 +6619,12 @@ snapshots:
|
||||
- rolldown
|
||||
- supports-color
|
||||
|
||||
ai@6.0.177(zod@4.3.6):
|
||||
ai@6.0.199(zod@4.3.6):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.112(zod@4.3.6)
|
||||
'@ai-sdk/gateway': 3.0.127(zod@4.3.6)
|
||||
'@ai-sdk/provider': 3.0.10
|
||||
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/api': 1.9.1
|
||||
zod: 4.3.6
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
@ -8604,10 +8644,18 @@ snapshots:
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
swr@2.4.1(react@19.2.4):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
tailwindcss@4.2.1: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
throttleit@2.1.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
157
specs/0005-onboarding-agent.md
Normal file
157
specs/0005-onboarding-agent.md
Normal file
@ -0,0 +1,157 @@
|
||||
# Onboarding agent
|
||||
|
||||
## Status
|
||||
|
||||
Proposed (June 2026) — v1 product spec, pending technical design.
|
||||
|
||||
> **Update (June 2026):** the first PR ships the chat + live strategy generation
|
||||
> only. Persisting the strategy (the "Project Context" store, R2 versioning) and
|
||||
> the `get_project_context` MCP tool described below are **deferred to a later
|
||||
> PR** — the generated strategy is shown in the chat but not yet saved.
|
||||
|
||||
## Goal
|
||||
|
||||
Turn signup into activation. When a new user onboards, an "agent" analyzes
|
||||
their actual website live, then proposes a tailored SEO strategy. The strategy
|
||||
is free and stands on its own; _acting on it_ (rank tracking, content briefs,
|
||||
the ongoing coach) is the paid surface. The strategy becomes the project's
|
||||
durable "Project Context" — readable in the app and over MCP.
|
||||
|
||||
This is the top activation priority because it gives every new user a concrete,
|
||||
personalized "here's what to do" moment before they're asked to pay.
|
||||
|
||||
## What "agent" means here
|
||||
|
||||
A **guided pipeline narrated live**, not an LLM agent loop. The steps are
|
||||
mostly deterministic (scrape, fetch keyword data); a single LLM call at the end
|
||||
synthesizes the strategy narrative. The "agent" feeling comes from streaming the
|
||||
steps in real time ("Reading your homepage… you look like a Notion
|
||||
alternative… 4 keywords ranking, 30 worth targeting…") and streaming the final
|
||||
write-up token-by-token. No Anthropic SDK / agent infra is required for v1.
|
||||
|
||||
## The experience
|
||||
|
||||
**Stage 0 — Profile (extend existing onboarding forms).** Collect: domain,
|
||||
experience level, and primary goal. **Website and default country are captured
|
||||
on the same step**, since the country is a property of the site/project being
|
||||
analyzed — keeping them together makes the relationship obvious and avoids a
|
||||
stray standalone country field. That step carries a short note that they can add
|
||||
more projects with different websites (and their own countries) later, so users
|
||||
don't feel they must cram every site into this first one. Keep the whole stage
|
||||
to ~4 fields — the onboarding UX audit flagged form friction. Country and
|
||||
language become the project's default location (see Project defaults) and are
|
||||
reused for every DataForSEO call going forward.
|
||||
|
||||
**Stage 1 — Discover (live).** Sitemap-first using existing robots.txt +
|
||||
sitemap discovery; fall back to a shallow crawl. Produces a map of the site's
|
||||
shape from URLs/titles. Discover many URLs (cheap); scrape few.
|
||||
|
||||
**Stage 2 — Read (live).** Scrape ~3–5 key pages (home + top product/nav pages)
|
||||
to markdown. This is the one net-new capability: page → markdown via Cloudflare
|
||||
Browser Rendering. Honest "we couldn't read your site" flagging with a manual
|
||||
"tell us what you do" fallback so the run never dead-ends.
|
||||
|
||||
**Stage 3 — Signal (live).** See Stage 3 data below.
|
||||
|
||||
**Stage 4 — Synthesize (streaming LLM).** One LLM call over
|
||||
{profile + scraped markdown + keyword data} produces: a positioning statement,
|
||||
3–5 themes/clusters, a starter keyword table (volume/difficulty), and a
|
||||
prioritized "do this next" list. Must produce a credible strategy even when the
|
||||
site has zero existing rankings — the cold-start case is the _default_ for the
|
||||
indie-founder ICP, not an edge case.
|
||||
|
||||
**Stage 5 — Persist + gate.** Save as the Project Context artifact (markdown,
|
||||
MCP-readable). Present the full strategy free; the paywall lands on _executing_
|
||||
it.
|
||||
|
||||
## Stage 3 data (v1 — kept minimal)
|
||||
|
||||
Stage 3 is **archetype-conditional**: detect the site type cheaply, then frame
|
||||
the goal accordingly. For v1 the archetype branches the _narrative_, not a large
|
||||
matrix of API calls. Two calls baseline:
|
||||
|
||||
| Call | Endpoint | Purpose | When |
|
||||
| ---------------------- | ------------------------------------------------------ | ---------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `domain_rank_overview` | `/v3/dataforseo_labs/google/domain_rank_overview/live` | traffic + # ranking keywords; the archetype detector | always |
|
||||
| `keyword_ideas` | `/v3/dataforseo_labs/google/keyword_ideas/live` | starter keyword list, seeded from scraped themes | always |
|
||||
| `ranked_keywords` | `/v3/dataforseo_labs/google/ranked_keywords/live` | what they already rank for | only if overview shows real rankings (usually skipped for new sites) |
|
||||
|
||||
Archetypes (narrative only in v1): **new/pre-traffic**, **established content
|
||||
site**, **local business**, **SaaS/product** (core ICP).
|
||||
|
||||
**Clarifying question:** for **beginners**, skip it and use the archetype's
|
||||
default goal (fewer decisions = less drop-off). For **non-beginners**, ask one
|
||||
targeted question after detection to confirm intent (e.g. "grow existing
|
||||
rankings, or expand into new topics?") — agency without a survey.
|
||||
|
||||
**Deferred to v2:** `serp_competitors`, local-business tools
|
||||
(`get_local_serp_results`, `search_local_businesses`,
|
||||
`get_google_business_questions`), and Google Ads volume for non-Labs countries.
|
||||
|
||||
## Cost per free onboarding
|
||||
|
||||
The whole run is free (paywall is after), so cost-per-signup must be bounded:
|
||||
|
||||
- DataForSEO: ~$0.04–0.08 (2 Labs live calls; metered from the real `cost`
|
||||
field in the response envelope, so we can hard-cap).
|
||||
- Browser Rendering scrape: ~negligible.
|
||||
- LLM synthesis: ~$0.05–0.15 (one call over a few pages of markdown).
|
||||
- **Total ≈ $0.10–0.25 per onboarding.**
|
||||
|
||||
Guardrails: one run per user, results cached hard, and **email verification
|
||||
gates Stage 3** (the DataForSEO spend) to kill drive-by abuse.
|
||||
|
||||
## Project Context artifact (MCP-readable)
|
||||
|
||||
The strategy is stored as a **markdown document** — a living "Project Context"
|
||||
that is the shared source of truth across the in-app agent, the web UI, and any
|
||||
MCP client.
|
||||
|
||||
- Storage: a `project_context` row in D1 (`projectId`, `markdown`, `updatedAt`,
|
||||
`version`). Small enough for D1; R2 only if it grows.
|
||||
- MCP: a `get_project_context` tool (and ideally an MCP _resource_ so it
|
||||
auto-loads into the agent's context). The user's own Claude/Codex reads the
|
||||
same strategy the app shows.
|
||||
- The onboarding agent **authors** the context; the paid execution actions
|
||||
(rank tracking, briefs, coach) **read from and append to** it. That append-back
|
||||
is the natural shape of the gated surface.
|
||||
|
||||
## Project defaults (country/language)
|
||||
|
||||
Projects don't currently store a default location — only `rank_tracking_configs`
|
||||
does (defaults 2840/US, "en"). Add `location_code` + `language_code` to
|
||||
`projects`, set from the Stage 0 country, and reuse them everywhere project work
|
||||
needs a location. A small curated country → `location_code` map is enough for
|
||||
v1.
|
||||
|
||||
## Paywall
|
||||
|
||||
Free: the full strategy, positioning, themes, capped starter keyword list
|
||||
(~15–20), and the "do next" list. Gated (execution): rank-tracking the proposed
|
||||
keywords, full keyword expansion, content briefs per theme, and the ongoing
|
||||
`seo-coach`. Every gated action is a named thing tied to a strategy item the
|
||||
user already believes in — stronger pull than "refine further." GSC stays behind
|
||||
the gate; we do **not** prompt for it during onboarding (connecting then hitting
|
||||
a paywall is exactly the bait-and-switch the UX audit warned against).
|
||||
|
||||
## Unify the seo-coach skill
|
||||
|
||||
Update the `seo-coach` skill (and `onboarding-checklist`) so the coach and the
|
||||
onboarding agent are one continuous experience: the coach reads Project Context
|
||||
via MCP, picks up where onboarding left off ("here's your strategy — let's work
|
||||
the backlog"), and can answer both SEO questions and OpenSEO product questions.
|
||||
The onboarding agent sets the backlog; the coach executes it.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Country → `location_code` source: hand-curate a short list for v1, or reuse an
|
||||
existing DataForSEO locations dataset?
|
||||
- Re-run policy: confirmed one strategy per project, regenerate in place; new
|
||||
domain = new project (a soft upgrade nudge).
|
||||
- Synthesis model choice (cost vs quality) — decide at technical design.
|
||||
|
||||
## Out of scope for v1
|
||||
|
||||
Conversational/iterative agent loop, competitor and local-business analysis,
|
||||
GSC-enriched strategy, multi-language strategies, automatic content generation.
|
||||
These are incremental improvements once the core activation loop is proven.
|
||||
151
specs/0006-onboarding-agent-implementation.md
Normal file
151
specs/0006-onboarding-agent-implementation.md
Normal file
@ -0,0 +1,151 @@
|
||||
# Onboarding agent — implementation plan (chat + seed function)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (June 2026) — technical plan for `specs/0005-onboarding-agent.md`.
|
||||
|
||||
Supersedes an earlier draft that proposed Cloudflare Project Think + Durable
|
||||
Objects + a Workflow. We dropped all of that (see "Why not Think / Workflows").
|
||||
|
||||
> **Update (June 2026):** to keep the first PR small, strategy **persistence**
|
||||
> (the `project_context_versions` store + R2 markdown versioning) and the
|
||||
> `get_project_context` **MCP tool** were deferred to a later PR. `seed.ts` now
|
||||
> returns the synthesized strategy to the chat without saving it; the
|
||||
> `claimRun` spend guard still bounds the paid run. Sections below describing
|
||||
> the store, versioning, and the MCP read tool reflect the original plan, not
|
||||
> what shipped.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Onboarding has two simple pieces, no agent framework:
|
||||
|
||||
1. **A seed function** (plain async): discover sitemap → scrape 3–5 pages to
|
||||
markdown (Browser Rendering) → 2 paid DataForSEO calls → one OpenRouter
|
||||
synthesis call → save the result as the project's first **Project Context**
|
||||
version. Runs once when onboarding kicks off.
|
||||
2. **A normal streaming chat** (Vercel AI SDK `streamText` over OpenRouter):
|
||||
the user asks questions / refines; an `update_project_context` tool writes a
|
||||
new version. Backed by a plain API route on the existing better-auth session.
|
||||
|
||||
The Project Context is **versioned**: immutable markdown blobs in R2, an
|
||||
append-only log in D1. Reverts reuse a prior blob's key. It's exposed over MCP
|
||||
via `get_project_context`.
|
||||
|
||||
## Why not Think / Workflows (and why `agents` stays)
|
||||
|
||||
- **No durable execution needed.** The paid DataForSEO services are _cache-first_
|
||||
(`getCached` runs before `createDataforseoClient`/metering), so a crash-and-retry
|
||||
of the same domain re-hits the 12h R2 cache → no double-spend. That removed the
|
||||
only reason for fibers/Workflows.
|
||||
- **No agent host needed.** The capabilities we want (stream answers, a future
|
||||
docs tool, save/update the artifact) are all plain `streamText({ tools })`.
|
||||
Think's distinctive value (durable DO sessions, scheduled turns, sub-agents)
|
||||
isn't used by any of them.
|
||||
- **The `agents` package stays** — it's used by the MCP handler
|
||||
(`agents/mcp` → `createMcpHandler` in `src/server/mcp/transport.ts`), not as an
|
||||
agent runtime. No version bump.
|
||||
- **Graduate later** only when a capability genuinely needs durable sessions,
|
||||
`schedule` (weekly rank tracking), or sub-agents (per-competitor). The seed
|
||||
function and chat route port straight over.
|
||||
|
||||
## Data model
|
||||
|
||||
**`projects`** — add `location_code` (int, default 2840), `language_code` (text,
|
||||
default `'en'`), `onboarding_run_status` (text nullable:
|
||||
`running|complete|failed`), `onboarding_run_at` (text nullable).
|
||||
|
||||
**`project_context_versions`** (new, append-only log):
|
||||
`id` pk · `project_id` FK · `r2_key` · `author` (`onboarding|chat|user`) ·
|
||||
`note` nullable · `reverted_from_id` nullable · `created_at`. Current version =
|
||||
latest row per project. Index on `(project_id, created_at)`.
|
||||
|
||||
**R2** — immutable markdown blob per version at
|
||||
`project-context/{projectId}/{versionId}.md`. **Write R2 first, then the D1
|
||||
row** (a failure leaves a harmless orphan blob, never a row pointing at nothing).
|
||||
A revert inserts a new row reusing the target's `r2_key` (no new blob).
|
||||
|
||||
## The seed function
|
||||
|
||||
`runOnboardingSeed({ projectId, organizationId, userId, userEmail, domain })`:
|
||||
|
||||
1. **Admission marker** — atomic `UPDATE projects SET onboarding_run_status =
|
||||
'running' WHERE id = ? AND onboarding_run_status IS NULL`; proceed only if
|
||||
one row changed (else a run is already in flight). This is the at-most-once
|
||||
guard.
|
||||
2. **Discover** — `fetch()` robots.txt + sitemap.xml; shallow fallback.
|
||||
3. **Read** — scrape 3–5 key pages to markdown via the `BROWSER` binding,
|
||||
sequentially. Failure → flag it, never throw the whole run.
|
||||
4. **Signal** — `DomainService.getOverview` (always) + keyword research seeded
|
||||
from scraped themes, both with `creditFeature: 'onboarding'`;
|
||||
`getSuggestedKeywords` only if the overview shows real rankings.
|
||||
5. **Synthesize** — one OpenRouter `generateText`/`streamText` over
|
||||
{profile + markdown + signal} → strategy markdown.
|
||||
6. **Persist** — write version `v1` (author `onboarding`); set
|
||||
`onboarding_run_status = 'complete'`. On any throw → `'failed'` (re-runnable;
|
||||
retry is cache-backed and cheap).
|
||||
|
||||
**Metering** is unchanged: paid calls go through the existing
|
||||
`createDataforseoClient` seam. A new `'onboarding'` `CreditFeature` tags spend.
|
||||
A `skipBalanceAssert` flag on the metering path lets a zero-balance new signup
|
||||
run Stage "Signal" without dead-ending on `INSUFFICIENT_CREDITS`, while still
|
||||
calling `trackDataforseoCost` (spend metered, not balance-gated). Self-host
|
||||
already skips Autumn entirely.
|
||||
|
||||
## The chat
|
||||
|
||||
`POST /api/onboarding/chat` (TanStack `createFileRoute` server handler):
|
||||
|
||||
- Auth: resolve the better-auth session from the request; assert the user owns
|
||||
`projectId` (org scoping via `ProjectRepository.getProjectForOrganization`).
|
||||
No new transport, so no extra auth surface.
|
||||
- `streamText({ model: openrouter(MODEL), system: seededWithContext, messages,
|
||||
tools: { update_project_context } })`, returned as a UI message stream.
|
||||
- `update_project_context({ markdown, note })` writes a new version (author
|
||||
`chat`). For v1 it applies directly; the append-only log makes any unwanted
|
||||
change one revert away.
|
||||
|
||||
Client: AI SDK `useChat({ api: '/api/onboarding/chat' })`. The strategy renders
|
||||
above the chat; the upgrade CTA is a UI state shown once `v1` exists.
|
||||
|
||||
## Auth + email-verify
|
||||
|
||||
- Surface `emailVerified` on `EnsuredUserContext` (from
|
||||
`session.user.emailVerified`); self-host = treated verified.
|
||||
- The seed asserts `emailVerified` before the paid "Signal" stage. Discover +
|
||||
Read (free) may run unverified.
|
||||
|
||||
## MCP
|
||||
|
||||
`get_project_context(projectId)` — read-only tool (`readOnlyHint: true`) using
|
||||
`withMcpProjectAuth`; resolves the latest version → R2 get → returns markdown.
|
||||
`list_project_context_versions` is a fast-follow.
|
||||
|
||||
## Local testing
|
||||
|
||||
Real providers, no fixture system:
|
||||
|
||||
- Add `OPENROUTER_API_KEY` to `.env.local` (the one new key), `wrangler login`
|
||||
for the `BROWSER` binding (`remote: true` in dev).
|
||||
- DataForSEO creds you already have; the cache makes repeat runs free.
|
||||
- Drive the real onboarding UI with the Playwriter skill, screenshotting each
|
||||
step. **Test domain: `openseo.so`.**
|
||||
|
||||
## Build order (stacked PRs)
|
||||
|
||||
1. **Foundation** — schema + migration (`projects` cols, `project_context_versions`);
|
||||
`emailVerified` on context; `'onboarding'` CreditFeature + label +
|
||||
`skipBalanceAssert` flag; country→`location_code` map. No behavior.
|
||||
2. **Stage 0 form** — domain + country on one step → `projects`.
|
||||
3. **Project Context store** — R2 blob helper + versions repository +
|
||||
`get_project_context` MCP tool.
|
||||
4. **Scrape + seed + synthesis** — `BROWSER` binding, scrape-to-markdown, the
|
||||
seed function, OpenRouter dep, the "generating → strategy" UI.
|
||||
5. **Chat** — `/api/onboarding/chat` + `update_project_context` tool +
|
||||
`useChat` UI + revert.
|
||||
|
||||
(There is no fixture-provider PR — we test against real providers.)
|
||||
|
||||
## Out of scope (v1)
|
||||
|
||||
Think/DO/Workflows, sub-agents, scheduled rank tracking, GSC-enriched strategy,
|
||||
multi-language, auto content generation, the MCP context _resource_ (tool only).
|
||||
141
src/client/components/Markdown.tsx
Normal file
141
src/client/components/Markdown.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
type Props = {
|
||||
/** Raw Markdown source to render. */
|
||||
children: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared Markdown renderer with explicit per-element Tailwind classes.
|
||||
*
|
||||
* OpenSEO doesn't ship `@tailwindcss/typography`, so `prose` classes are
|
||||
* no-ops — every block element is styled here instead. Tables use daisyUI's
|
||||
* `table table-xs` so model- and strategy-generated tables stay readable.
|
||||
*
|
||||
* Anchor URLs are sanitized to http(s) only — LLMs can be coaxed into
|
||||
* emitting `javascript:` payloads.
|
||||
*/
|
||||
export function Markdown({ children, className }: Props) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AnchorProps = ComponentPropsWithoutRef<"a">;
|
||||
|
||||
function SafeAnchor({ href, children, ...rest }: AnchorProps) {
|
||||
const safeHref = isHttpUrl(href) ? href : undefined;
|
||||
if (!safeHref) {
|
||||
return <span className="underline decoration-dotted">{children}</span>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link link-primary"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string | undefined): value is string {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||
// Mirror server-side `safeHttpUrl` — a `user:pass@host` URL shows one
|
||||
// hostname in link text while auth hits another.
|
||||
if (url.username || url.password) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const MARKDOWN_COMPONENTS = {
|
||||
h1: ({ children }: { children?: ReactNode }) => (
|
||||
<h1 className="mt-5 mb-2 text-base font-semibold first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: ReactNode }) => (
|
||||
<h2 className="mt-5 mb-2 text-sm font-semibold first:mt-0">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: ReactNode }) => (
|
||||
<h3 className="mt-4 mb-1.5 text-sm font-semibold first:mt-0">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className="mt-3 mb-1 text-sm font-semibold first:mt-0">{children}</h4>
|
||||
),
|
||||
p: ({ children }: { children?: ReactNode }) => (
|
||||
<p className="my-2 leading-relaxed first:mt-0 last:mb-0">{children}</p>
|
||||
),
|
||||
ul: ({ children }: { children?: ReactNode }) => (
|
||||
<ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: ReactNode }) => (
|
||||
<ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }: { children?: ReactNode }) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
a: SafeAnchor,
|
||||
strong: ({ children }: { children?: ReactNode }) => (
|
||||
<strong className="font-semibold">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: ReactNode }) => (
|
||||
<em className="italic">{children}</em>
|
||||
),
|
||||
blockquote: ({ children }: { children?: ReactNode }) => (
|
||||
<blockquote className="my-2 border-l-2 border-base-300 pl-3 text-base-content/80 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-3 border-base-300" />,
|
||||
code: ({ children, className }: ComponentPropsWithoutRef<"code">) => {
|
||||
// Inline code (no `language-*` className from remark) gets the badge style;
|
||||
// block code is rendered by `pre` with a different shell.
|
||||
if (typeof className === "string" && className.startsWith("language-")) {
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-base-200 px-1 py-0.5 text-xs font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }: { children?: ReactNode }) => (
|
||||
<pre className="my-2 overflow-x-auto rounded-lg bg-base-200 p-3 text-xs font-mono">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }: { children?: ReactNode }) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table className="table table-xs border border-base-300">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: { children?: ReactNode }) => <thead>{children}</thead>,
|
||||
tbody: ({ children }: { children?: ReactNode }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }: { children?: ReactNode }) => (
|
||||
<tr className="border-b border-base-300 last:border-0">{children}</tr>
|
||||
),
|
||||
th: ({ children }: { children?: ReactNode }) => (
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{children}</th>
|
||||
),
|
||||
td: ({ children }: { children?: ReactNode }) => (
|
||||
<td className="px-2 py-1.5 align-top">{children}</td>
|
||||
),
|
||||
};
|
||||
@ -1,13 +1,8 @@
|
||||
import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import Markdown from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { MARKDOWN_COMPONENTS } from "@/client/components/Markdown";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
@ -71,12 +66,12 @@ export function MarkdownAnswer({ text }: Props) {
|
||||
}
|
||||
className={isCollapsed ? "overflow-hidden" : undefined}
|
||||
>
|
||||
<Markdown
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{normalized}
|
||||
</Markdown>
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{isCollapsed ? (
|
||||
@ -171,112 +166,3 @@ function normalizeLlmMarkdown(text: string): string {
|
||||
"$1$2 ",
|
||||
);
|
||||
}
|
||||
|
||||
type AnchorProps = ComponentPropsWithoutRef<"a">;
|
||||
|
||||
function SafeAnchor({ href, children, ...rest }: AnchorProps) {
|
||||
const safeHref = isHttpUrl(href) ? href : undefined;
|
||||
if (!safeHref) {
|
||||
return <span className="underline decoration-dotted">{children}</span>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link link-primary"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string | undefined): value is string {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||
// Mirror server-side `safeHttpUrl` — a `user:pass@host` URL shows one
|
||||
// hostname in link text while auth hits another.
|
||||
if (url.username || url.password) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
h1: ({ children }: { children?: ReactNode }) => (
|
||||
<h1 className="mt-4 mb-2 text-base font-semibold first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: ReactNode }) => (
|
||||
<h2 className="mt-4 mb-2 text-sm font-semibold first:mt-0">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: ReactNode }) => (
|
||||
<h3 className="mt-3 mb-1.5 text-sm font-semibold first:mt-0">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className="mt-3 mb-1 text-sm font-semibold first:mt-0">{children}</h4>
|
||||
),
|
||||
p: ({ children }: { children?: ReactNode }) => (
|
||||
<p className="my-2 first:mt-0 last:mb-0">{children}</p>
|
||||
),
|
||||
ul: ({ children }: { children?: ReactNode }) => (
|
||||
<ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: ReactNode }) => (
|
||||
<ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }: { children?: ReactNode }) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
a: SafeAnchor,
|
||||
strong: ({ children }: { children?: ReactNode }) => (
|
||||
<strong className="font-semibold">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: ReactNode }) => (
|
||||
<em className="italic">{children}</em>
|
||||
),
|
||||
blockquote: ({ children }: { children?: ReactNode }) => (
|
||||
<blockquote className="my-2 border-l-2 border-base-300 pl-3 text-base-content/80 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-3 border-base-300" />,
|
||||
code: ({ children, className }: ComponentPropsWithoutRef<"code">) => {
|
||||
// Inline code (no `language-*` className from remark) gets the badge style;
|
||||
// block code is rendered by `pre` with a different shell.
|
||||
if (typeof className === "string" && className.startsWith("language-")) {
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-base-200 px-1 py-0.5 text-xs font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }: { children?: ReactNode }) => (
|
||||
<pre className="my-2 overflow-x-auto rounded-lg bg-base-200 p-3 text-xs font-mono">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }: { children?: ReactNode }) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table className="table table-xs border border-base-300">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: { children?: ReactNode }) => <thead>{children}</thead>,
|
||||
tbody: ({ children }: { children?: ReactNode }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }: { children?: ReactNode }) => (
|
||||
<tr className="border-b border-base-300 last:border-0">{children}</tr>
|
||||
),
|
||||
th: ({ children }: { children?: ReactNode }) => (
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{children}</th>
|
||||
),
|
||||
td: ({ children }: { children?: ReactNode }) => (
|
||||
<td className="px-2 py-1.5 align-top">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
125
src/client/features/onboarding/OnboardingStrategy.tsx
Normal file
125
src/client/features/onboarding/OnboardingStrategy.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
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">
|
||||
Couldn’t 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>
|
||||
);
|
||||
}
|
||||
259
src/client/features/onboarding/OnboardingStrategyChat.tsx
Normal file
259
src/client/features/onboarding/OnboardingStrategyChat.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import { useCustomer } from "autumn-js/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Markdown } from "@/client/components/Markdown";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
|
||||
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
|
||||
import {
|
||||
ChatComposer,
|
||||
ChatGate,
|
||||
SuggestedQuestions,
|
||||
UpgradeSidebar,
|
||||
WelcomeMessage,
|
||||
} from "./OnboardingStrategyChatParts";
|
||||
|
||||
function messageHasText(message: UIMessage): boolean {
|
||||
return message.parts.some(
|
||||
(part) => part.type === "text" && part.text.trim().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubble({ message }: { message: UIMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end pl-8 sm:pl-16">
|
||||
<div className="rounded-box rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-content">
|
||||
{message.parts.map((part, index) =>
|
||||
part.type === "text" ? (
|
||||
<span key={index} className="whitespace-pre-wrap">
|
||||
{part.text}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2 pt-0.5 text-sm">
|
||||
{message.parts.map((part, index) =>
|
||||
part.type === "text" && part.text.trim() ? (
|
||||
<Markdown key={index}>{part.text}</Markdown>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUGGESTED_QUESTIONS = [
|
||||
"How does OpenSEO help me get more traffic?",
|
||||
"Why is OpenSEO better than Claude?",
|
||||
"What do I get after I upgrade?",
|
||||
"How does Google Search Console work in OpenSEO?",
|
||||
];
|
||||
|
||||
// Offered as a highlighted chip only when the user hasn't already asked for
|
||||
// their strategy via the welcome CTA. Clicking it prompts Sam to draft/show it.
|
||||
const STRATEGY_SUGGESTION = "What do you recommend for my site?";
|
||||
|
||||
export function StrategyChat({
|
||||
projectId,
|
||||
domain,
|
||||
}: {
|
||||
projectId: string;
|
||||
domain: string;
|
||||
}) {
|
||||
const { messages, sendMessage, status, error } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/onboarding/chat",
|
||||
body: { projectId },
|
||||
}),
|
||||
});
|
||||
|
||||
// This chat is only ever the pre-upgrade free preview: once a user upgrades
|
||||
// they are routed into the GSC onboarding step and never return here, so
|
||||
// there's no "paid" state to model — the question cap always applies.
|
||||
const customerQuery = useCustomer();
|
||||
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
||||
const [isStartingCheckout, setIsStartingCheckout] = useState(false);
|
||||
const [usedSuggestions, setUsedSuggestions] = useState<string[]>([]);
|
||||
// Set once the user asks for their strategy (welcome CTA or the strategy
|
||||
// chip) so we don't keep offering the "What do you recommend" chip.
|
||||
const [strategyRequested, setStrategyRequested] = useState(false);
|
||||
|
||||
const questionsUsed = messages.filter((m) => m.role === "user").length;
|
||||
const remaining = Math.max(0, FREE_ONBOARDING_QUESTION_LIMIT - questionsUsed);
|
||||
const isLocked = remaining <= 0;
|
||||
// Nudge once they're within the last few questions, not from the start.
|
||||
const showRemainingHint = remaining > 0 && remaining <= 3;
|
||||
|
||||
const isBusy = status === "submitted" || status === "streaming";
|
||||
const sendText = (text: string) => void sendMessage({ text });
|
||||
async function startCheckout() {
|
||||
setCheckoutError(null);
|
||||
setIsStartingCheckout(true);
|
||||
try {
|
||||
captureClientEvent("billing:checkout_start");
|
||||
// After payment, re-enter onboarding at the GSC step (not back into this
|
||||
// chat) so the user finishes connecting Search Console + MCP.
|
||||
const successUrl = new URL("/onboarding", window.location.origin);
|
||||
successUrl.searchParams.set("step", "3");
|
||||
successUrl.searchParams.set("checkout", "success");
|
||||
await customerQuery.attach({
|
||||
planId: AUTUMN_PAID_PLAN_ID,
|
||||
redirectMode: "always",
|
||||
successUrl: successUrl.toString(),
|
||||
});
|
||||
} catch (checkoutErr) {
|
||||
console.error("Failed to start checkout", checkoutErr);
|
||||
setCheckoutError(
|
||||
"We couldn't start checkout. Please refresh and try again.",
|
||||
);
|
||||
setIsStartingCheckout(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Pin to the bottom while the user is following along; the strategy doc plus
|
||||
// a streaming reply quickly grows past the viewport.
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, status]);
|
||||
|
||||
// Show a typing indicator until the assistant's reply starts streaming text.
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const suggestionPool = strategyRequested
|
||||
? SUGGESTED_QUESTIONS
|
||||
: [STRATEGY_SUGGESTION, ...SUGGESTED_QUESTIONS];
|
||||
const remainingSuggestions = suggestionPool.filter(
|
||||
(question) => !usedSuggestions.includes(question),
|
||||
);
|
||||
const showTyping = isBusy && (!lastMessage || !messageHasText(lastMessage));
|
||||
const showSuggestions =
|
||||
remainingSuggestions.length > 0 &&
|
||||
!isBusy &&
|
||||
messages.length > 0 &&
|
||||
lastMessage?.role === "assistant";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<UpgradeSidebar
|
||||
domain={domain}
|
||||
questionsUsed={questionsUsed}
|
||||
isStartingCheckout={isStartingCheckout}
|
||||
onUpgrade={() => void startCheckout()}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-6">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<WelcomeMessage
|
||||
domain={domain}
|
||||
checkoutError={checkoutError}
|
||||
isStartingCheckout={isStartingCheckout}
|
||||
onUpgrade={() => void startCheckout()}
|
||||
onAskAboutOpenSeo={() =>
|
||||
sendText("I have questions about OpenSEO before I upgrade.")
|
||||
}
|
||||
onProposeStrategy={() => {
|
||||
setStrategyRequested(true);
|
||||
sendText(
|
||||
`Please analyze ${domain} and show me my SEO strategy.`,
|
||||
);
|
||||
}}
|
||||
disableActions={isBusy || messages.length > 0}
|
||||
/>
|
||||
|
||||
{messages.map((message) => (
|
||||
<ChatBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{showTyping ? (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 pt-2 text-base-content/40">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === "error" ? (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-error/10 text-error">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<p className="pt-1 text-sm text-error">
|
||||
{/* useChat surfaces the failed response body as error.message;
|
||||
the server's 402 backstop sends the paywall copy below. */}
|
||||
{(error?.message ?? "").includes("free strategy questions")
|
||||
? "You've reached the free question limit. Upgrade to continue."
|
||||
: "Something went wrong. Please refresh and try again."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSuggestions ? (
|
||||
<SuggestedQuestions
|
||||
questions={remainingSuggestions}
|
||||
primaryQuestion={STRATEGY_SUGGESTION}
|
||||
onSelect={(question) => {
|
||||
setUsedSuggestions((current) =>
|
||||
current.includes(question)
|
||||
? current
|
||||
: [...current, question],
|
||||
);
|
||||
if (question === STRATEGY_SUGGESTION) {
|
||||
setStrategyRequested(true);
|
||||
}
|
||||
sendText(question);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLocked ? (
|
||||
<ChatGate
|
||||
isStartingCheckout={isStartingCheckout}
|
||||
onUpgrade={() => void startCheckout()}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-shrink-0 border-t border-base-300 px-5 py-3">
|
||||
<div className="mx-auto w-full max-w-2xl space-y-2">
|
||||
{showRemainingHint ? (
|
||||
<p className="px-1 text-xs text-base-content/50">
|
||||
{remaining} free question{remaining === 1 ? "" : "s"} left.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="link link-primary"
|
||||
disabled={isStartingCheckout}
|
||||
onClick={() => void startCheckout()}
|
||||
>
|
||||
Upgrade for full access
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
<ChatComposer busy={isBusy} onSend={sendText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
339
src/client/features/onboarding/OnboardingStrategyChatParts.tsx
Normal file
339
src/client/features/onboarding/OnboardingStrategyChatParts.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Check,
|
||||
Globe,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
|
||||
|
||||
const DISCORD_URL = "https://discord.gg/c9uGs3cFXr";
|
||||
|
||||
export function SuggestedQuestions({
|
||||
questions,
|
||||
primaryQuestion,
|
||||
onSelect,
|
||||
}: {
|
||||
questions: string[];
|
||||
primaryQuestion?: string;
|
||||
onSelect: (question: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-10 flex flex-wrap gap-2">
|
||||
{questions.map((question) =>
|
||||
question === primaryQuestion ? (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/15"
|
||||
onClick={() => onSelect(question)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
{question}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
className="rounded-full border border-base-300 bg-base-100 px-3 py-1.5 text-xs font-medium text-base-content/70 transition-colors hover:border-primary/50 hover:text-base-content"
|
||||
onClick={() => onSelect(question)}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeMessage({
|
||||
domain,
|
||||
checkoutError,
|
||||
isStartingCheckout,
|
||||
onUpgrade,
|
||||
onAskAboutOpenSeo,
|
||||
onProposeStrategy,
|
||||
disableActions,
|
||||
}: {
|
||||
domain: string;
|
||||
checkoutError: string | null;
|
||||
isStartingCheckout: boolean;
|
||||
onUpgrade: () => void;
|
||||
onAskAboutOpenSeo: () => void;
|
||||
onProposeStrategy: () => void;
|
||||
disableActions: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-3 pt-0.5 text-sm">
|
||||
<div className="space-y-3 text-base-content/80">
|
||||
<p>Hey, I’m Sam — welcome to OpenSEO.</p>
|
||||
<p>
|
||||
You’re on the free preview: {FREE_ONBOARDING_QUESTION_LIMIT}{" "}
|
||||
questions with me before you upgrade. I can draft an SEO strategy
|
||||
for <span className="font-medium text-base-content">{domain}</span>{" "}
|
||||
and answer questions about OpenSEO or SEO.
|
||||
</p>
|
||||
<p>
|
||||
Want me to analyze{" "}
|
||||
<span className="font-medium text-base-content">{domain}</span> and
|
||||
draft a strategy, or do you have questions first?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-soft btn-sm"
|
||||
disabled={disableActions}
|
||||
onClick={onAskAboutOpenSeo}
|
||||
>
|
||||
Ask about OpenSEO
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={disableActions}
|
||||
onClick={onProposeStrategy}
|
||||
>
|
||||
Show my strategy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-box border border-base-300 bg-base-200/50 p-3 text-xs lg:hidden">
|
||||
<p className="font-medium">Want Sam to keep going?</p>
|
||||
<p className="mt-0.5 text-base-content/70">
|
||||
Upgrade to run keyword research, rank tracking, and site audits on{" "}
|
||||
{domain}.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-xs mt-2"
|
||||
disabled={isStartingCheckout}
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
{isStartingCheckout ? "Redirecting..." : "Upgrade"}
|
||||
</button>
|
||||
{checkoutError ? (
|
||||
<p className="mt-2 text-error">{checkoutError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Left-rail upgrade CTA. Hidden below `lg` (the inline callout + remaining
|
||||
// hint cover narrow viewports).
|
||||
export function UpgradeSidebar({
|
||||
domain,
|
||||
questionsUsed,
|
||||
isStartingCheckout,
|
||||
onUpgrade,
|
||||
}: {
|
||||
domain: string;
|
||||
questionsUsed: number;
|
||||
isStartingCheckout: boolean;
|
||||
onUpgrade: () => void;
|
||||
}) {
|
||||
const features = [
|
||||
"Keyword research, backlinks, rank tracking & site audits",
|
||||
"Google Search Console — read-only, no credits, no Google Cloud setup",
|
||||
"Connect Claude, Cursor, Codex & other MCP clients",
|
||||
"Top-up credits roll over and never expire",
|
||||
];
|
||||
const used = Math.min(questionsUsed, FREE_ONBOARDING_QUESTION_LIMIT);
|
||||
const progress = (used / FREE_ONBOARDING_QUESTION_LIMIT) * 100;
|
||||
|
||||
return (
|
||||
<aside className="hidden w-96 flex-shrink-0 flex-col border-r border-base-300 bg-base-200/20 lg:flex">
|
||||
<div className="flex items-center gap-2.5 border-b border-base-300 px-6 py-4 text-xs text-base-content/55">
|
||||
<span className="inline-flex size-8 items-center justify-center rounded-full border border-base-300 bg-base-100 text-primary">
|
||||
<Globe className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-base-content/80">Previewing OpenSEO</p>
|
||||
<p className="truncate" title={domain}>
|
||||
{domain}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-5 px-6 py-6">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-3xl font-semibold tracking-tight">$10</span>
|
||||
<span className="text-sm text-base-content/55">/month</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-base-content/55">
|
||||
Includes $10 of usage credits every month, plus a 30-day money-back
|
||||
guarantee.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 border-t border-base-300 pt-5">
|
||||
{features.map((label) => (
|
||||
<li
|
||||
key={label}
|
||||
className="flex gap-2.5 text-sm leading-snug text-base-content/75"
|
||||
>
|
||||
<Check className="mt-0.5 size-4 flex-shrink-0 text-primary" />
|
||||
<span>{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto space-y-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isStartingCheckout}
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
{isStartingCheckout ? "Redirecting..." : "Upgrade to continue"}
|
||||
</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">
|
||||
Want advice from other OpenSEO users?{" "}
|
||||
<a
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link link-primary"
|
||||
>
|
||||
Join the Discord
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 border-t border-base-300 px-6 py-4">
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-base-300">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-base-content/55">
|
||||
{used} of {FREE_ONBOARDING_QUESTION_LIMIT} free questions used
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// Replaces the composer once a free user exhausts their question allowance.
|
||||
export function ChatGate({
|
||||
isStartingCheckout,
|
||||
onUpgrade,
|
||||
}: {
|
||||
isStartingCheckout: boolean;
|
||||
onUpgrade: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-shrink-0 border-t border-base-300 px-5 py-4">
|
||||
<div className="mx-auto w-full max-w-2xl rounded-box border border-primary/30 bg-primary/5 p-4 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
That’s all {FREE_ONBOARDING_QUESTION_LIMIT} free questions
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-xs text-base-content/70">
|
||||
Upgrade to keep working with Sam and unlock the full OpenSEO app.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm mt-3"
|
||||
disabled={isStartingCheckout}
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
{isStartingCheckout ? "Redirecting..." : "Upgrade to continue"}
|
||||
</button>
|
||||
<p className="mt-2 text-xs text-base-content/45">
|
||||
30-day money-back guarantee
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
busy,
|
||||
onSend,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onSend: (text: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-grow the textarea up to a few lines, then scroll. Resetting height to
|
||||
// `auto` first lets it shrink as well as grow.
|
||||
useLayoutEffect(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
|
||||
}, [value]);
|
||||
|
||||
function submit() {
|
||||
const text = value.trim();
|
||||
if (!text || busy) return;
|
||||
onSend(text);
|
||||
setValue("");
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
|
||||
function handleKey(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-end gap-2 rounded-box border border-base-300 bg-base-100 px-3 py-2 focus-within:border-primary"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
rows={1}
|
||||
placeholder="Ask Sam about your strategy or OpenSEO…"
|
||||
className="max-h-40 flex-1 resize-none border-0 bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-base-content/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
disabled={busy || !value.trim()}
|
||||
className="btn btn-primary btn-circle btn-sm"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -23,6 +23,7 @@ type PostSignupOnboardingProps = {
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
onFinish: (mcpSetupIntent: "yes" | "no") => void;
|
||||
onUpgradeAcknowledged: () => void;
|
||||
isSaving: boolean;
|
||||
accountMenu: ReactNode;
|
||||
};
|
||||
@ -38,6 +39,7 @@ export function PostSignupOnboarding({
|
||||
onBack,
|
||||
onSkip,
|
||||
onFinish,
|
||||
onUpgradeAcknowledged,
|
||||
isSaving,
|
||||
accountMenu,
|
||||
}: PostSignupOnboardingProps) {
|
||||
@ -53,6 +55,55 @@ export function PostSignupOnboarding({
|
||||
const updateAnswers = (patch: Partial<OnboardingAnswers>) =>
|
||||
onAnswersChange({ ...answers, ...patch });
|
||||
|
||||
// After a successful checkout the user lands on the GSC step with
|
||||
// `?checkout=success`. Show a one-time "you're in" screen (same layout as the
|
||||
// steps) and only reveal the actual GSC step once they continue, which drops
|
||||
// the param.
|
||||
const justUpgraded =
|
||||
step === 3 &&
|
||||
typeof window !== "undefined" &&
|
||||
new URLSearchParams(window.location.search).get("checkout") === "success";
|
||||
|
||||
if (justUpgraded) {
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{accountMenu}
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<img
|
||||
src="/transparent-logo.png"
|
||||
alt="OpenSEO"
|
||||
className="mx-auto size-10 rounded-lg"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">You’re in! 🎉</h1>
|
||||
<p className="text-sm text-base-content/60">
|
||||
Your subscription’s active.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Finish setting up your account
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-base-content/70">
|
||||
Two quick steps left — connect Google Search Console, then set up
|
||||
MCP for your agent.
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-soft"
|
||||
onClick={onUpgradeAcknowledged}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{accountMenu}
|
||||
|
||||
@ -14,8 +14,8 @@ export const INTEREST_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
export const WORK_FOR_OPTIONS = [
|
||||
"My clients",
|
||||
"My own startup or business",
|
||||
"My clients",
|
||||
"My employer's website",
|
||||
"My own side project",
|
||||
"I'm exploring before choosing a project",
|
||||
|
||||
13
src/client/features/onboarding/onboardingStrategyQueries.ts
Normal file
13
src/client/features/onboarding/onboardingStrategyQueries.ts
Normal file
@ -0,0 +1,13 @@
|
||||
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"] });
|
||||
}
|
||||
@ -5,7 +5,6 @@ import {
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
CreditCard,
|
||||
FolderCog,
|
||||
Menu,
|
||||
Settings,
|
||||
User,
|
||||
@ -16,10 +15,7 @@ import {
|
||||
SeoApiStatusBanners,
|
||||
} from "@/client/layout/AppShellParts";
|
||||
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
|
||||
import {
|
||||
getProjectNavGroups,
|
||||
projectSettingsLinkOptions,
|
||||
} from "@/client/navigation/items";
|
||||
import { getProjectNavGroups } from "@/client/navigation/items";
|
||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { BILLING_ROUTE } from "@/shared/billing";
|
||||
@ -302,22 +298,16 @@ function TopNav({
|
||||
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
|
||||
<ProjectSwitcher activeProjectId={projectId} variant="topbar" />
|
||||
|
||||
<AccountMenu projectId={projectId} />
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountMenu mobileOnly projectId={projectId} />
|
||||
<AccountMenu mobileOnly />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountMenu({
|
||||
mobileOnly = false,
|
||||
projectId,
|
||||
}: {
|
||||
mobileOnly?: boolean;
|
||||
projectId?: string | null;
|
||||
}) {
|
||||
function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
||||
const { data: session } = useSession();
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const email = session?.user?.email;
|
||||
@ -362,17 +352,6 @@ function AccountMenu({
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{projectId ? (
|
||||
<li>
|
||||
<Link
|
||||
{...projectSettingsLinkOptions(projectId)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FolderCog className="h-4 w-4" />
|
||||
Project settings
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
<Link to="/settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
|
||||
@ -120,15 +120,6 @@ export function getProjectNavGroups(projectId: string) {
|
||||
];
|
||||
}
|
||||
|
||||
// Per-project configuration (name, domain, Search Console, delete) lives on the
|
||||
// project settings page rather than the workflow nav, hence the projectId param.
|
||||
export function projectSettingsLinkOptions(projectId: string) {
|
||||
return linkOptions({
|
||||
to: "/p/$projectId/settings" as const,
|
||||
params: { projectId },
|
||||
});
|
||||
}
|
||||
|
||||
export const dataforseoHelpLinkOptions = linkOptions({
|
||||
to: "/help/dataforseo-api-key",
|
||||
});
|
||||
|
||||
@ -50,6 +50,14 @@ export const projects = sqliteTable(
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
domain: text("domain"),
|
||||
// Default DataForSEO location/language for the project, set during
|
||||
// onboarding and reused by every project-scoped data call.
|
||||
locationCode: integer("location_code").notNull().default(2840),
|
||||
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")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
|
||||
10
src/env.d.ts
vendored
10
src/env.d.ts
vendored
@ -24,6 +24,11 @@ declare namespace Cloudflare {
|
||||
|
||||
// DataForSEO API Basic auth value (base64 of login:password)
|
||||
DATAFORSEO_API_KEY: string;
|
||||
|
||||
// OpenRouter API key for onboarding strategy synthesis + chat.
|
||||
OPENROUTER_API_KEY?: string;
|
||||
// Optional OpenRouter model slug override (defaults in openrouter.ts).
|
||||
OPENROUTER_MODEL?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,3 +44,8 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module "*.md?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
@ -65,6 +65,8 @@ export async function resolveDelegatedContext(
|
||||
return {
|
||||
userId,
|
||||
userEmail: ensuredEmail,
|
||||
// Delegated auth (Cloudflare Access / local) has no unverified state.
|
||||
emailVerified: true,
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ export async function resolveHostedContext(
|
||||
return {
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email,
|
||||
emailVerified: session.user.emailVerified ?? false,
|
||||
organizationId: activeOrganizationId,
|
||||
};
|
||||
}
|
||||
@ -49,6 +50,7 @@ export async function resolveHostedContext(
|
||||
return {
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email,
|
||||
emailVerified: session.user.emailVerified ?? false,
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
22
src/middleware/ensure-user/resolve.ts
Normal file
22
src/middleware/ensure-user/resolve.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { resolveCloudflareAccessContext } from "./cloudflareAccess";
|
||||
import { resolveLocalNoAuthContext } from "./delegated";
|
||||
import { resolveHostedContext } from "./hosted";
|
||||
import type { EnsuredUserContext } from "./types";
|
||||
|
||||
// Resolves the authenticated user for a request's headers across every auth
|
||||
// mode. Shared by ensureUserMiddleware (server functions) and raw API routes,
|
||||
// which can't use function middleware.
|
||||
export async function resolveUserContextFromHeaders(
|
||||
headers: Headers,
|
||||
): Promise<EnsuredUserContext> {
|
||||
const authMode = getAuthMode(env.AUTH_MODE);
|
||||
if (authMode === "local_noauth") {
|
||||
return resolveLocalNoAuthContext();
|
||||
}
|
||||
if (isHostedAuthMode(authMode)) {
|
||||
return resolveHostedContext(headers);
|
||||
}
|
||||
return resolveCloudflareAccessContext(headers);
|
||||
}
|
||||
@ -7,6 +7,10 @@ export type EnsuredProject = NonNullable<
|
||||
export type EnsuredUserContext = {
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
// True when the user's email is verified (hosted) or auth is delegated
|
||||
// (Cloudflare Access / local), where there is no unverified state. Used to
|
||||
// gate paid onboarding spend behind verification.
|
||||
emailVerified: boolean;
|
||||
organizationId: string;
|
||||
project?: EnsuredProject;
|
||||
};
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
|
||||
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
|
||||
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
|
||||
import type {
|
||||
EnsuredProject,
|
||||
EnsuredUserContext,
|
||||
} from "@/middleware/ensure-user/types";
|
||||
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
|
||||
import type { EnsuredProject } from "@/middleware/ensure-user/types";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
function extractProjectId(data: unknown) {
|
||||
if (!data || typeof data !== "object" || !("projectId" in data)) {
|
||||
@ -26,17 +19,7 @@ function extractProjectId(data: unknown) {
|
||||
export const ensureUserMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next, data }) => {
|
||||
const authMode = getAuthMode(env.AUTH_MODE);
|
||||
const headers = getRequest().headers;
|
||||
let context: EnsuredUserContext;
|
||||
|
||||
if (authMode === "local_noauth") {
|
||||
context = await resolveLocalNoAuthContext();
|
||||
} else if (isHostedAuthMode(authMode)) {
|
||||
context = await resolveHostedContext(headers);
|
||||
} else {
|
||||
context = await resolveCloudflareAccessContext(headers);
|
||||
}
|
||||
const context = await resolveUserContextFromHeaders(getRequest().headers);
|
||||
|
||||
const projectId = extractProjectId(data);
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route'
|
||||
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
||||
import { Route as AppIndexRouteImport } from './routes/_app/index'
|
||||
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
|
||||
import { Route as AuthenticatedOnboardingRouteImport } from './routes/_authenticated.onboarding'
|
||||
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
|
||||
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||
@ -28,8 +27,11 @@ import { Route as AppProjectsRouteImport } from './routes/_app/projects'
|
||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||
import { Route as AppAiRouteImport } from './routes/_app/ai'
|
||||
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
||||
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 ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
|
||||
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||
@ -89,11 +91,6 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
|
||||
path: '/subscribe',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedOnboardingRoute = AuthenticatedOnboardingRouteImport.update({
|
||||
id: '/onboarding',
|
||||
path: '/onboarding',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedOauthConsentRoute =
|
||||
AuthenticatedOauthConsentRouteImport.update({
|
||||
id: '/oauth-consent',
|
||||
@ -141,6 +138,17 @@ const Char91DotwellKnownChar93OpenaiAppsChallengeRoute =
|
||||
path: '/.well-known/openai-apps-challenge',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedOnboardingIndexRoute =
|
||||
AuthenticatedOnboardingIndexRouteImport.update({
|
||||
id: '/onboarding/',
|
||||
path: '/onboarding/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const ApiOnboardingChatRoute = ApiOnboardingChatRouteImport.update({
|
||||
id: '/api/onboarding/chat',
|
||||
path: '/api/onboarding/chat',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
||||
id: '/api/autumn/$',
|
||||
path: '/api/autumn/$',
|
||||
@ -151,6 +159,12 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedOnboardingChatRoute =
|
||||
AuthenticatedOnboardingChatRouteImport.update({
|
||||
id: '/onboarding/chat',
|
||||
path: '/onboarding/chat',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
|
||||
id: '/help/dataforseo-api-key',
|
||||
path: '/help/dataforseo-api-key',
|
||||
@ -261,12 +275,14 @@ export interface FileRoutesByFullPath {
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/onboarding': typeof AuthenticatedOnboardingRoute
|
||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
|
||||
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
@ -297,11 +313,13 @@ export interface FileRoutesByTo {
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/onboarding': typeof AuthenticatedOnboardingRoute
|
||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
|
||||
'/onboarding': typeof AuthenticatedOnboardingIndexRoute
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
@ -334,13 +352,15 @@ export interface FileRoutesById {
|
||||
'/_auth/sign-in': typeof AuthSignInRoute
|
||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/_authenticated/onboarding': typeof AuthenticatedOnboardingRoute
|
||||
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/_app/': typeof AppIndexRoute
|
||||
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
|
||||
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
@ -373,12 +393,14 @@ export interface FileRouteTypes {
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/oauth-consent'
|
||||
| '/onboarding'
|
||||
| '/subscribe'
|
||||
| '/p/$projectId'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/onboarding/chat'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/onboarding/chat'
|
||||
| '/onboarding/'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
@ -409,11 +431,13 @@ export interface FileRouteTypes {
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/oauth-consent'
|
||||
| '/onboarding'
|
||||
| '/subscribe'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/onboarding/chat'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/onboarding/chat'
|
||||
| '/onboarding'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
| '/p/$projectId/domain'
|
||||
@ -445,13 +469,15 @@ export interface FileRouteTypes {
|
||||
| '/_auth/sign-in'
|
||||
| '/_auth/sign-up'
|
||||
| '/_authenticated/oauth-consent'
|
||||
| '/_authenticated/onboarding'
|
||||
| '/_authenticated/subscribe'
|
||||
| '/_app/'
|
||||
| '/_project/p/$projectId'
|
||||
| '/_app/help/dataforseo-api-key'
|
||||
| '/_authenticated/onboarding/chat'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/onboarding/chat'
|
||||
| '/_authenticated/onboarding/'
|
||||
| '/_project/p/$projectId/audit'
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
| '/_project/p/$projectId/brand-lookup'
|
||||
@ -480,6 +506,7 @@ export interface RootRouteChildren {
|
||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||
ApiOnboardingChatRoute: typeof ApiOnboardingChatRoute
|
||||
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
|
||||
}
|
||||
|
||||
@ -548,13 +575,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/onboarding': {
|
||||
id: '/_authenticated/onboarding'
|
||||
path: '/onboarding'
|
||||
fullPath: '/onboarding'
|
||||
preLoaderRoute: typeof AuthenticatedOnboardingRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/oauth-consent': {
|
||||
id: '/_authenticated/oauth-consent'
|
||||
path: '/oauth-consent'
|
||||
@ -618,6 +638,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/onboarding/': {
|
||||
id: '/_authenticated/onboarding/'
|
||||
path: '/onboarding'
|
||||
fullPath: '/onboarding/'
|
||||
preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport
|
||||
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/$': {
|
||||
id: '/api/autumn/$'
|
||||
path: '/api/autumn/$'
|
||||
@ -632,6 +666,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/onboarding/chat': {
|
||||
id: '/_authenticated/onboarding/chat'
|
||||
path: '/onboarding/chat'
|
||||
fullPath: '/onboarding/chat'
|
||||
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_app/help/dataforseo-api-key': {
|
||||
id: '/_app/help/dataforseo-api-key'
|
||||
path: '/help/dataforseo-api-key'
|
||||
@ -872,14 +913,16 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute
|
||||
AuthenticatedOnboardingRoute: typeof AuthenticatedOnboardingRoute
|
||||
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
|
||||
AuthenticatedOnboardingChatRoute: typeof AuthenticatedOnboardingChatRoute
|
||||
AuthenticatedOnboardingIndexRoute: typeof AuthenticatedOnboardingIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute,
|
||||
AuthenticatedOnboardingRoute: AuthenticatedOnboardingRoute,
|
||||
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
|
||||
AuthenticatedOnboardingChatRoute: AuthenticatedOnboardingChatRoute,
|
||||
AuthenticatedOnboardingIndexRoute: AuthenticatedOnboardingIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
@ -898,6 +941,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||
ApiOnboardingChatRoute: ApiOnboardingChatRoute,
|
||||
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
6
src/routes/_authenticated.onboarding.chat.tsx
Normal file
6
src/routes/_authenticated.onboarding.chat.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { OnboardingStrategy } from "@/client/features/onboarding/OnboardingStrategy";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/onboarding/chat")({
|
||||
component: OnboardingStrategy,
|
||||
});
|
||||
@ -21,16 +21,10 @@ import { saveOnboardingAnswers } from "@/serverFunctions/onboarding";
|
||||
|
||||
const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z";
|
||||
|
||||
// First step that requires a subscription. The earlier steps (interests, who
|
||||
// you work for, how you found us) collect profiling answers we want even from
|
||||
// users who bounce at the paywall, so the gate sits here — after them, before
|
||||
// Search Console + MCP setup.
|
||||
const SUBSCRIBE_GATE_STEP = 3;
|
||||
|
||||
const clampStep = (step: number) =>
|
||||
Math.min(Math.max(0, Math.trunc(step)), ONBOARDING_LAST_STEP);
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/onboarding")({
|
||||
export const Route = createFileRoute("/_authenticated/onboarding/")({
|
||||
// Step lives in the URL so it survives refresh and works with back/forward.
|
||||
validateSearch: (search: Record<string, unknown>): { step: number } => {
|
||||
const raw = Number(search.step);
|
||||
@ -90,9 +84,8 @@ function OnboardingFlow({
|
||||
const { step } = Route.useSearch();
|
||||
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
|
||||
|
||||
// Self-hosted has no paywall; hosted users must subscribe before the gated
|
||||
// steps. Answers from earlier steps are already saved, so a user who pays
|
||||
// returns to the gated step with everything intact.
|
||||
// Self-hosted has no paywall. Hosted users now get a short strategy chat
|
||||
// before the subscribe gate, so this only feeds later paid onboarding steps.
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const accessQuery = useQuery({
|
||||
...managedAccessQueryOptions(),
|
||||
@ -117,14 +110,17 @@ function OnboardingFlow({
|
||||
const goToStep = (next: number) =>
|
||||
void navigate({ to: "/onboarding", search: { step: clampStep(next) } });
|
||||
|
||||
// Advance to the next step, but divert to the paywall when crossing into the
|
||||
// first gated step. The just-saved answers let the user resume here on return.
|
||||
const advanceFromCurrentStep = () => {
|
||||
if (step === 2) {
|
||||
void navigate({ to: "/onboarding/chat", replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const next = clampStep(step + 1);
|
||||
if (next >= SUBSCRIBE_GATE_STEP && needsSubscription) {
|
||||
if (step >= 3 && needsSubscription) {
|
||||
void navigate({
|
||||
to: SUBSCRIBE_ROUTE,
|
||||
search: { redirect: `/onboarding?step=${SUBSCRIBE_GATE_STEP}` },
|
||||
search: { redirect: `/onboarding?step=${next}` },
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
@ -187,6 +183,9 @@ function OnboardingFlow({
|
||||
onBack={() => goToStep(step - 1)}
|
||||
onSkip={handleSkip}
|
||||
onFinish={handleFinish}
|
||||
onUpgradeAcknowledged={() =>
|
||||
void navigate({ to: "/onboarding", search: { step }, replace: true })
|
||||
}
|
||||
isSaving={saveMutation.isPending}
|
||||
accountMenu={<OnboardingAccountMenu email={email} />}
|
||||
/>
|
||||
@ -3,7 +3,7 @@ import { env } from "cloudflare:workers";
|
||||
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
|
||||
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { responseForAppError } from "@/server/lib/http-errors";
|
||||
import { handleSelfHostedGscOAuthCallback } from "@/server/features/gsc/selfHostedOAuth";
|
||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||
|
||||
@ -17,22 +17,6 @@ async function resolveSelfHostedContext(request: Request) {
|
||||
: resolveCloudflareAccessContext(request.headers);
|
||||
}
|
||||
|
||||
function responseForError(error: unknown) {
|
||||
if (error instanceof AppError) {
|
||||
const status =
|
||||
error.code === "UNAUTHENTICATED"
|
||||
? 401
|
||||
: error.code === "FORBIDDEN"
|
||||
? 403
|
||||
: error.code === "VALIDATION_ERROR"
|
||||
? 400
|
||||
: 500;
|
||||
return new Response(error.message, { status });
|
||||
}
|
||||
|
||||
return new Response("Search Console OAuth failed", { status: 500 });
|
||||
}
|
||||
|
||||
async function handleCallbackRequest(request: Request) {
|
||||
try {
|
||||
const context = await resolveSelfHostedContext(request);
|
||||
@ -47,7 +31,7 @@ async function handleCallbackRequest(request: Request) {
|
||||
publicOrigin: getPublicOrigin(request),
|
||||
});
|
||||
} catch (error) {
|
||||
return responseForError(error);
|
||||
return responseForAppError(error, "Search Console OAuth failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
162
src/routes/api/onboarding/chat.ts
Normal file
162
src/routes/api/onboarding/chat.ts
Normal file
@ -0,0 +1,162 @@
|
||||
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");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,12 +1,21 @@
|
||||
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
|
||||
import { z } from "zod";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
import type { CreditFeature } from "@/shared/billing-credit-features";
|
||||
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
||||
import { normalizeDomainInput } from "@/server/lib/domainUtils";
|
||||
import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper";
|
||||
import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage";
|
||||
import { getPagesPage } from "@/server/features/domain/services/domainPagesPage";
|
||||
|
||||
// Lets a caller (e.g. the onboarding seed) attribute spend to its own feature
|
||||
// and bypass the balance gate. Applied to the DataForSEO call, not the cache
|
||||
// key, so cached results are shared across callers.
|
||||
type MeteringOverrides = {
|
||||
creditFeature?: CreditFeature;
|
||||
skipBalanceAssert?: boolean;
|
||||
};
|
||||
|
||||
/** Domain overview data is refreshed every 12 hours. */
|
||||
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
|
||||
|
||||
@ -31,6 +40,7 @@ async function getOverview(
|
||||
languageCode: string;
|
||||
},
|
||||
billingCustomer: BillingCustomerContext,
|
||||
metering: MeteringOverrides = {},
|
||||
): Promise<DomainOverviewResult> {
|
||||
const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
|
||||
|
||||
@ -56,6 +66,7 @@ async function getOverview(
|
||||
target: domain,
|
||||
locationCode: input.locationCode,
|
||||
languageCode: input.languageCode,
|
||||
...metering,
|
||||
});
|
||||
|
||||
const metrics = metricsResponse[0];
|
||||
@ -99,6 +110,7 @@ async function getSuggestedKeywords(
|
||||
projectId: string;
|
||||
},
|
||||
billingCustomer: BillingCustomerContext,
|
||||
metering: MeteringOverrides = {},
|
||||
): Promise<
|
||||
Array<{
|
||||
keyword: string;
|
||||
@ -144,6 +156,7 @@ async function getSuggestedKeywords(
|
||||
languageCode: input.languageCode,
|
||||
limit: 100,
|
||||
orderBy: ["ranked_serp_element.serp_item.etv,desc"],
|
||||
...metering,
|
||||
});
|
||||
|
||||
const keywords = rankedKeywordsResponse.items
|
||||
|
||||
129
src/server/features/onboarding/openseo-fact-sheet.md
Normal file
129
src/server/features/onboarding/openseo-fact-sheet.md
Normal file
@ -0,0 +1,129 @@
|
||||
# OpenSEO Fact Sheet
|
||||
|
||||
This is the factual product reference for Sam, the OpenSEO onboarding agent. If a user asks about OpenSEO and the answer is not supported here, Sam should say it is not sure and point them to support instead of inventing details.
|
||||
|
||||
## What OpenSEO is
|
||||
|
||||
OpenSEO is an open-source SEO platform for keyword research, domain research, backlinks, rank tracking, site audits, Google Search Console, and AI-agent SEO workflows.
|
||||
|
||||
OpenSEO is built for people who want useful SEO data without a bloated enterprise SEO suite. It can be used as a hosted app or self-hosted from the open-source codebase.
|
||||
|
||||
OpenSEO is AI-native. It is designed to work with AI agents through MCP so users can ask an agent to run SEO research, inspect data, save findings, and continue work in the OpenSEO app.
|
||||
|
||||
OpenSEO does not claim to fully automate SEO. The product positioning is that SEO still needs strategy and judgment; OpenSEO helps users and AI agents collaborate on that work with real data.
|
||||
|
||||
## How OpenSEO helps with SEO strategy
|
||||
|
||||
SEO and marketing are intertwined. Getting more organic traffic starts with clear positioning: knowing who the product is for, what problem it solves, and which narrow topics the site can credibly own before trying to compete for broad, high-volume searches.
|
||||
|
||||
OpenSEO helps users turn that positioning into an SEO plan. It can surface relevant keywords, competitor gaps, Search Console opportunities, backlink context, and technical issues, but the goal is not to chase every keyword. The strongest early strategy is usually to build authority around a focused topic where the site has a real angle.
|
||||
|
||||
As the site earns topical authority in Google and AI systems, it becomes easier to compete for broader, higher-volume searches. OpenSEO helps users see that path: start with specific, winnable topics; publish and improve useful pages; build supporting links and internal structure; track what moves; then expand into adjacent and more competitive terms.
|
||||
|
||||
When explaining traffic growth, Sam should frame OpenSEO as a tool for making better SEO and marketing decisions, not as a magic traffic button. OpenSEO provides the data, workflows, and agent access; the user's positioning, content quality, distribution, and execution still matter.
|
||||
|
||||
## Hosted plan and credits
|
||||
|
||||
The managed OpenSEO app costs $10/month.
|
||||
|
||||
The managed plan includes:
|
||||
|
||||
- Keyword research, backlinks, rank tracking, and site audits.
|
||||
- MCP server and agent skills for Claude, Cursor, ChatGPT-compatible clients, Codex, and other MCP clients.
|
||||
- Google Search Console integration that does not use credits.
|
||||
- $10.00 of usage credits each billing cycle.
|
||||
- A 30-day money-back guarantee for the first charge.
|
||||
|
||||
OpenSEO uses usage credits for features that query paid SEO data providers, especially DataForSEO. Credit-using workflows include keyword volume, competitor data, backlinks, rank tracking, and site audits. Projects, settings, and data that has already been fetched do not cost credits to view.
|
||||
|
||||
Top-up credits can be purchased if monthly credits run out. Top-up credits roll over and do not expire. Monthly included credits reset each billing cycle.
|
||||
|
||||
Hosted users need an active subscription to use OpenSEO. If credits run out, OpenSEO should not create unexpected bills; users can buy more credits.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
OpenSEO is open source and can be self-hosted for free.
|
||||
|
||||
Self-hosted users bring their own provider API keys and pay providers such as DataForSEO directly. Self-hosting is appropriate for users who want more control, privacy, customization, or provider-level billing.
|
||||
|
||||
The open-source repository is at `https://github.com/every-app/open-seo`.
|
||||
|
||||
## Data sources
|
||||
|
||||
OpenSEO uses DataForSEO as its main SEO data provider. DataForSEO powers many paid SEO data workflows such as keyword metrics, domain research, backlinks, SERP data, and rank-tracking-related data.
|
||||
|
||||
Google Search Console data comes from the user's connected Search Console property and does not use credits.
|
||||
|
||||
## Google Search Console
|
||||
|
||||
Hosted OpenSEO can connect to Google Search Console without requiring the user to create a Google Cloud project or OAuth client.
|
||||
|
||||
Search Console access is read-only. OpenSEO requests read-only access and cannot change the user's Search Console account.
|
||||
|
||||
Search Console features include:
|
||||
|
||||
- Search performance data: clicks, impressions, CTR, and average position.
|
||||
- Breakdown by query, page, country, device, and date.
|
||||
- Up to 16 months of available Search Console history.
|
||||
- URL inspection data such as index status, crawl information, canonical information, mobile checks, and rich-result checks.
|
||||
- Up to 10 URLs per URL inspection call.
|
||||
|
||||
Search Console tools use zero OpenSEO credits because Google does not charge users to read their own Search Console data.
|
||||
|
||||
## MCP and AI agents
|
||||
|
||||
OpenSEO exposes an MCP server so compatible AI clients can call OpenSEO tools.
|
||||
|
||||
Hosted MCP endpoint:
|
||||
|
||||
```txt
|
||||
https://app.openseo.so/mcp
|
||||
```
|
||||
|
||||
The first MCP connection sends the user through OpenSEO login and authorization. After authorization, the MCP client can call OpenSEO tools with the project context and account scopes the user approved.
|
||||
|
||||
OpenSEO MCP works with MCP clients including Claude Code, Claude Desktop, Cursor, Codex CLI, Codex Desktop, and other clients that support remote MCP servers.
|
||||
|
||||
OpenSEO MCP tools cover workflows such as:
|
||||
|
||||
- Keyword research with volume, difficulty, CPC, intent, and trends.
|
||||
- Live Google organic SERP inspection.
|
||||
- Domain and page ranked keyword research.
|
||||
- SERP competitor comparisons.
|
||||
- Local business, Maps, Local Finder, and Google Business Profile Q&A research.
|
||||
- Saved keyword listing and saving.
|
||||
- Rank tracker config and latest position reads.
|
||||
- Domain organic footprint summaries.
|
||||
- Backlink and referring-domain overview data.
|
||||
- Google Search Console performance reads.
|
||||
- Google URL inspection reads.
|
||||
|
||||
OpenSEO also provides agent skills for workflows such as SEO project setup, SEO coaching, keyword research, competitive landscape analysis, competitor analysis, keyword clustering, and link prospecting.
|
||||
|
||||
## App workflows
|
||||
|
||||
OpenSEO's app includes these practical workflows:
|
||||
|
||||
- Keyword research: expand seed topics into keyword ideas, compare search volume, difficulty, CPC, intent, and SERP context, then save useful opportunities.
|
||||
- Domain overview: understand a domain's organic footprint and ranking keywords.
|
||||
- Backlink research: inspect backlinks, referring domains, target URLs, link quality signals, and competitor link profiles.
|
||||
- Rank tracking: track keyword positions over time.
|
||||
- Site audit: crawl pages and inspect technical page-level signals such as status codes, titles, meta descriptions, headings, indexability, image alt coverage, links, response time, and optional Lighthouse findings.
|
||||
- Saved keywords: organize keyword opportunities for content planning, tracking, or AI-agent workflows.
|
||||
- AI and MCP setup: connect OpenSEO to agents and install OpenSEO skills.
|
||||
|
||||
## What users can do after subscribing
|
||||
|
||||
After subscribing, a hosted user can:
|
||||
|
||||
- Set up Google Search Console from onboarding or the app.
|
||||
- Use the OpenSEO app workflows, including keyword research, domain research, backlinks, rank tracking, and site audits.
|
||||
- Connect OpenSEO to an AI client through MCP.
|
||||
- Install OpenSEO skills for agent-driven SEO workflows.
|
||||
- Use the monthly included credits and buy top-up credits if needed.
|
||||
|
||||
## Support and uncertainty
|
||||
|
||||
If Sam is unsure about a product detail, current pricing, account-specific billing status, provider limits, or a feature not listed here, it should say it does not know from the product fact sheet and suggest contacting `ben@openseo.so`.
|
||||
|
||||
Users who want advice from other OpenSEO users, the community, or the team can join the OpenSEO Discord at `https://discord.gg/c9uGs3cFXr`.
|
||||
28
src/server/features/onboarding/scrape.test.ts
Normal file
28
src/server/features/onboarding/scrape.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readSite } from "@/server/features/onboarding/scrape";
|
||||
|
||||
describe("readSite SSRF guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("blocks a metadata/private host without fetching it", async () => {
|
||||
const result = await readSite("169.254.169.254");
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.pages).toEqual([]);
|
||||
// The blocked host must be rejected before any outbound page fetch.
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks localhost-style targets", async () => {
|
||||
const result = await readSite("localhost:3000");
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
175
src/server/features/onboarding/scrape.ts
Normal file
175
src/server/features/onboarding/scrape.ts
Normal file
@ -0,0 +1,175 @@
|
||||
// Lightweight, dependency-free site reading for onboarding: discover a few URLs
|
||||
// from the sitemap (falling back to the homepage) and extract readable text from
|
||||
// each page via plain fetch. This is enough to let the model infer what a site
|
||||
// does. JS-heavy sites degrade gracefully (less text); a Browser Rendering
|
||||
// upgrade can slot in behind this same interface later.
|
||||
|
||||
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
|
||||
|
||||
const MAX_PAGES = 5;
|
||||
const PER_PAGE_CHAR_LIMIT = 4000;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_RESPONSE_BYTES = 2_000_000;
|
||||
const USER_AGENT = "OpenSEO-Onboarding/1.0 (+https://openseo.so)";
|
||||
|
||||
export type ScrapedPage = {
|
||||
url: string;
|
||||
title: string | null;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type SiteReadResult = {
|
||||
rootUrl: string;
|
||||
pages: ScrapedPage[];
|
||||
/** True when we couldn't read any page (blocked, offline, etc.). */
|
||||
blocked: boolean;
|
||||
};
|
||||
|
||||
// Bounded read: accumulate up to MAX_RESPONSE_BYTES regardless of whether
|
||||
// content-length is present (chunked / CDN responses often omit it).
|
||||
async function readBoundedText(response: Response): Promise<string | null> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return null;
|
||||
const decoder = new TextDecoder();
|
||||
let result = "";
|
||||
let bytesRead = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytesRead += value.byteLength;
|
||||
if (bytesRead > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
result += decoder.decode(value, { stream: true });
|
||||
}
|
||||
result += decoder.decode();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { "user-agent": USER_AGENT, accept: "text/html,*/*" },
|
||||
// Manual redirects so we can re-validate each hop against the SSRF guard;
|
||||
// redirect:"follow" would let a 30x to an internal host bypass it.
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) return null;
|
||||
let redirectUrl: string;
|
||||
try {
|
||||
// Re-validates host, blocks private IPs, and does DoH DNS resolution.
|
||||
redirectUrl = await normalizeAndValidateStartUrl(
|
||||
new URL(location, url).toString(),
|
||||
);
|
||||
} catch {
|
||||
return null; // blocked or invalid redirect destination
|
||||
}
|
||||
// One hop only; fetch the validated destination without following further.
|
||||
const redirected = await fetch(redirectUrl, {
|
||||
headers: { "user-agent": USER_AGENT, accept: "text/html,*/*" },
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!redirected.ok) return null;
|
||||
return await readBoundedText(redirected);
|
||||
}
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await readBoundedText(response);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls page URLs from a sitemap body. Resolves relative/protocol-relative
|
||||
* <loc> entries against the origin and keeps same-origin HTML pages only. Nested
|
||||
* sitemap files (a sitemap index) are skipped rather than fetched as pages —
|
||||
* good enough for v1; we fall back to the homepage if nothing usable is found.
|
||||
*/
|
||||
function parseSitemapUrls(xml: string, origin: string): string[] {
|
||||
const urls: string[] = [];
|
||||
const regex = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(xml)) !== null) {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = new URL(match[1], origin).toString();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (resolved.startsWith(origin) && !resolved.endsWith(".xml")) {
|
||||
urls.push(resolved);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function extractTitle(html: string): string | null {
|
||||
const match = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
|
||||
return match ? decodeEntities(match[1].trim()) : null;
|
||||
}
|
||||
|
||||
/** Strips scripts/styles/tags and collapses whitespace into readable text. */
|
||||
function htmlToText(html: string): string {
|
||||
const withoutBlocks = html
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ")
|
||||
.replace(/<!--[\s\S]*?-->/g, " ");
|
||||
const text = withoutBlocks
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return decodeEntities(text);
|
||||
}
|
||||
|
||||
function decodeEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ");
|
||||
}
|
||||
|
||||
/** Discovers and reads up to MAX_PAGES pages of a site as plain text. */
|
||||
export async function readSite(domain: string): Promise<SiteReadResult> {
|
||||
let rootUrl: string;
|
||||
try {
|
||||
rootUrl = await normalizeAndValidateStartUrl(domain);
|
||||
} catch {
|
||||
// Blocked (private/metadata host) or unparseable domain — nothing to read.
|
||||
return { rootUrl: `https://${domain}`, pages: [], blocked: true };
|
||||
}
|
||||
const origin = new URL(rootUrl).origin;
|
||||
|
||||
// Prefer the sitemap for representative URLs; always include the homepage.
|
||||
const sitemap = await fetchText(`${origin}/sitemap.xml`);
|
||||
const discovered = sitemap ? parseSitemapUrls(sitemap, origin) : [];
|
||||
const targets = [
|
||||
rootUrl,
|
||||
...discovered.filter((url) => url !== rootUrl),
|
||||
].slice(0, MAX_PAGES);
|
||||
|
||||
const pages: ScrapedPage[] = [];
|
||||
for (const url of targets) {
|
||||
const html = await fetchText(url);
|
||||
if (!html) {
|
||||
continue;
|
||||
}
|
||||
const text = htmlToText(html).slice(0, PER_PAGE_CHAR_LIMIT);
|
||||
if (text.length === 0) {
|
||||
continue;
|
||||
}
|
||||
pages.push({ url, title: extractTitle(html), text });
|
||||
}
|
||||
|
||||
return { rootUrl, pages, blocked: pages.length === 0 };
|
||||
}
|
||||
144
src/server/features/onboarding/seed.ts
Normal file
144
src/server/features/onboarding/seed.ts
Normal file
@ -0,0 +1,144 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
91
src/server/features/onboarding/synthesis.ts
Normal file
91
src/server/features/onboarding/synthesis.ts
Normal file
@ -0,0 +1,91 @@
|
||||
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();
|
||||
}
|
||||
@ -69,19 +69,26 @@ export { mapDataforseoPathToCreditFeature };
|
||||
*
|
||||
* `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
|
||||
* spend to its own feature). The extra field is ignored by the fetchers, which
|
||||
* spend to its own feature). The extra fields are ignored by the fetchers, which
|
||||
* 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>(
|
||||
customer: BillingCustomerContext,
|
||||
fetcher: (input: I) => Promise<DataforseoApiResponse<T>>,
|
||||
defaultFeature?: CreditFeature,
|
||||
): (input: I & { creditFeature?: CreditFeature }) => Promise<T> {
|
||||
): (
|
||||
input: I & { creditFeature?: CreditFeature; skipBalanceAssert?: boolean },
|
||||
) => Promise<T> {
|
||||
return (input) =>
|
||||
meterDataforseoCall(
|
||||
customer,
|
||||
() => fetcher(input),
|
||||
input.creditFeature ?? defaultFeature,
|
||||
input.skipBalanceAssert ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@ -148,6 +155,7 @@ async function meterDataforseoCall<T>(
|
||||
customer: BillingCustomerContext,
|
||||
execute: () => Promise<DataforseoApiResponse<T>>,
|
||||
creditFeature?: CreditFeature,
|
||||
skipBalanceAssert = false,
|
||||
): Promise<T> {
|
||||
const isHostedMode = await isHostedServerAuthMode();
|
||||
|
||||
@ -158,9 +166,11 @@ async function meterDataforseoCall<T>(
|
||||
|
||||
const billingCustomer = await getOrCreateOrganizationCustomer(customer);
|
||||
|
||||
const { monthlyRemaining } = await assertSeoDataBalanceAvailable(
|
||||
billingCustomer.id,
|
||||
);
|
||||
// The onboarding seed skips the balance gate so a zero-balance new signup
|
||||
// still gets a strategy; spend is tracked against monthly balance below.
|
||||
const { monthlyRemaining } = skipBalanceAssert
|
||||
? { monthlyRemaining: 0 }
|
||||
: await assertSeoDataBalanceAvailable(billingCustomer.id);
|
||||
|
||||
let result: DataforseoApiResponse<T>;
|
||||
try {
|
||||
|
||||
33
src/server/lib/http-errors.ts
Normal file
33
src/server/lib/http-errors.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
|
||||
function statusForAppError(code: string): number {
|
||||
switch (code) {
|
||||
case "UNAUTHENTICATED":
|
||||
return 401;
|
||||
case "FORBIDDEN":
|
||||
return 403;
|
||||
case "NOT_FOUND":
|
||||
return 404;
|
||||
case "VALIDATION_ERROR":
|
||||
return 400;
|
||||
case "PAYMENT_REQUIRED":
|
||||
return 402;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a thrown error to a Response for raw API routes (which, unlike server
|
||||
// functions, have no error middleware). AppErrors carry their status; anything
|
||||
// else becomes a 500 with the given fallback message.
|
||||
export function responseForAppError(
|
||||
error: unknown,
|
||||
fallbackMessage: string,
|
||||
): Response {
|
||||
if (error instanceof AppError) {
|
||||
return new Response(error.message, {
|
||||
status: statusForAppError(error.code),
|
||||
});
|
||||
}
|
||||
return new Response(fallbackMessage, { status: 500 });
|
||||
}
|
||||
18
src/server/lib/openrouter.ts
Normal file
18
src/server/lib/openrouter.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import type { LanguageModel } from "ai";
|
||||
import {
|
||||
getOptionalEnvValue,
|
||||
getRequiredEnvValue,
|
||||
} from "@/server/lib/runtime-env";
|
||||
|
||||
// OpenRouter model slug used for onboarding strategy synthesis + chat. Override
|
||||
// with OPENROUTER_MODEL to swap models without a code change.
|
||||
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> {
|
||||
const apiKey = await getRequiredEnvValue("OPENROUTER_API_KEY");
|
||||
const model =
|
||||
(await getOptionalEnvValue("OPENROUTER_MODEL")) ?? DEFAULT_ONBOARDING_MODEL;
|
||||
return createOpenRouter({ apiKey })(model);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||
const ensuredUserContextSchema: z.ZodType<EnsuredUserContext> = z.object({
|
||||
userId: z.string(),
|
||||
userEmail: z.string(),
|
||||
emailVerified: z.boolean(),
|
||||
organizationId: z.string(),
|
||||
project: z.any().optional(),
|
||||
});
|
||||
|
||||
78
src/serverFunctions/onboardingStrategy.ts
Normal file
78
src/serverFunctions/onboardingStrategy.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { projects } from "@/db/schema";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||
import { normalizeDomainInput } from "@/server/lib/domainUtils";
|
||||
import {
|
||||
getLanguageCode,
|
||||
isSupportedLocationCode,
|
||||
} from "@/shared/keyword-locations";
|
||||
|
||||
// Returns the onboarding project (id + domain). Uses the org's default project;
|
||||
// onboarding targets a single project in v1.
|
||||
export const getOnboardingStrategyState = createServerFn({ method: "GET" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.handler(async ({ context }) => {
|
||||
const [project] = await ProjectService.listProjectsEnsuringOne(
|
||||
context.organizationId,
|
||||
);
|
||||
if (!project) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
return {
|
||||
projectId: project.id,
|
||||
domain: project.domain,
|
||||
};
|
||||
});
|
||||
|
||||
const saveSiteSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
domain: z.string().min(1),
|
||||
locationCode: z.number().int(),
|
||||
});
|
||||
|
||||
// Persists the site + default location for the project before generation.
|
||||
export const saveOnboardingSite = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) => saveSiteSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const project = await ProjectRepository.getProjectForOrganization(
|
||||
data.projectId,
|
||||
context.organizationId,
|
||||
);
|
||||
if (!project) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!isSupportedLocationCode(data.locationCode)) {
|
||||
throw new AppError("VALIDATION_ERROR", "Unsupported location");
|
||||
}
|
||||
const newDomain = normalizeDomainInput(data.domain, false);
|
||||
const domainChanged = newDomain !== project.domain;
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
domain: newDomain,
|
||||
locationCode: 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(
|
||||
and(
|
||||
eq(projects.id, data.projectId),
|
||||
eq(projects.organizationId, context.organizationId),
|
||||
),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@ -6,7 +6,8 @@ export type CreditFeature =
|
||||
| "rank_tracking"
|
||||
| "ai_citations"
|
||||
| "ai_prompt_responses"
|
||||
| "local_seo";
|
||||
| "local_seo"
|
||||
| "onboarding";
|
||||
|
||||
const CREDIT_FEATURE_LABELS: Record<string, string> = {
|
||||
keyword_research: "Keyword Research",
|
||||
@ -18,6 +19,7 @@ const CREDIT_FEATURE_LABELS: Record<string, string> = {
|
||||
ai_prompt_responses: "AI Prompt Responses",
|
||||
ai_search: "AI Search",
|
||||
local_seo: "Local SEO",
|
||||
onboarding: "Onboarding",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
5
src/shared/onboardingChat.ts
Normal file
5
src/shared/onboardingChat.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// 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
|
||||
// (disables the composer, shows "N left") and the server backstop (rejects an
|
||||
// over-limit request that bypassed the client) agree on the same number.
|
||||
export const FREE_ONBOARDING_QUESTION_LIMIT = 7;
|
||||
Loading…
x
Reference in New Issue
Block a user