tweak onboarding prompt + add keyword research (#277)

This commit is contained in:
Ben Senescu 2026-06-19 20:25:11 -04:00 committed by GitHub
parent b2931a6542
commit 244cfe2204
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 464 additions and 496 deletions

View File

@ -7,6 +7,7 @@
"packageManager": "pnpm@10.30.1", "packageManager": "pnpm@10.30.1",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"dev:clear-chat": "rm -rf .wrangler/state/v3/do/open-seo-OnboardingChatAgent",
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
"build": "vite build && tsc --noEmit", "build": "vite build && tsc --noEmit",
@ -99,8 +100,8 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.13.18", "@cloudflare/vite-plugin": "^1.40.2",
"@cloudflare/workers-types": "^4.20251014.0", "@cloudflare/workers-types": "^4.20260611.1",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
@ -122,6 +123,6 @@
"vite": "^7.1.2", "vite": "^7.1.2",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"wrangler": "^4.45.3" "wrangler": "^4.100.0"
} }
} }

638
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ import { useAgentChat } from "@cloudflare/ai-chat/react";
import { type UIMessage } from "ai"; import { type UIMessage } from "ai";
import { useCustomer } from "autumn-js/react"; import { useCustomer } from "autumn-js/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Sparkles } from "lucide-react"; import { Sparkles, Loader2, Check, AlertTriangle } from "lucide-react";
import { Markdown } from "@/client/components/Markdown"; import { Markdown } from "@/client/components/Markdown";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
@ -16,37 +16,57 @@ import {
WelcomeMessage, WelcomeMessage,
} from "./OnboardingChatParts"; } from "./OnboardingChatParts";
function messageHasText(message: UIMessage): boolean { // Whether an assistant message already shows something — visible text or a tool
// badge. Used to decide when the standalone typing indicator is still needed: a
// running tool badge already reads as progress, so the dots would double up.
function messageHasVisibleContent(message: UIMessage): boolean {
return message.parts.some( return message.parts.some(
(part) => part.type === "text" && part.text.trim().length > 0, (part) =>
(part.type === "text" && part.text.trim().length > 0) ||
part.type.startsWith("tool-"),
); );
} }
// While Sam is running a tool, surface what it's doing so the wait reads as // Friendly labels for each tool Sam can run, so the chat shows what it's doing
// progress, not a hang — gathering site data takes a few seconds before any // rather than going silent while it gathers site data. `running` shows while the
// text streams back. // call is in flight; `done` stays as a persistent badge once it finishes.
function activeToolLabel( const TOOL_LABELS: Record<string, { running: string; done: string }> = {
message: UIMessage | undefined, "tool-read_website": { running: "Reading site", done: "Read site" },
domain: string, "tool-get_seo_metrics": {
): string | null { running: "Getting SEO metrics",
if (!message || message.role !== "assistant") return null; done: "SEO metrics",
for (const part of message.parts) { },
if (typeof part.type !== "string" || !part.type.startsWith("tool-")) { "tool-research_keywords": {
continue; running: "Researching keywords",
} done: "Keyword research",
// Tool parts carry a `state`; skip ones that have already finished so the },
// label only shows while a tool is actually in flight. };
if (
"state" in part && // A small inline badge for one tool call, rendered in document order inside the
(part.state === "output-available" || part.state === "output-error") // assistant bubble so the sequence of work stays visible after it completes.
) { function ToolBadge({ part }: { part: UIMessage["parts"][number] }) {
continue; const labels = TOOL_LABELS[part.type];
} if (!labels) return null;
if (part.type === "tool-read_website") return `Reading ${domain}`; const state = "state" in part ? part.state : undefined;
if (part.type === "tool-get_seo_metrics") return "Checking your rankings…"; const isError = state === "output-error";
return "Researching your site…"; const isDone = state === "output-available";
} const isRunning = !isError && !isDone;
return null; return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ${
isError ? "bg-error/10 text-error" : "bg-base-200 text-base-content/70"
}`}
>
{isRunning ? (
<Loader2 className="size-3 animate-spin" />
) : isError ? (
<AlertTriangle className="size-3" />
) : (
<Check className="size-3" />
)}
<span>{isRunning ? `${labels.running}` : labels.done}</span>
</span>
);
} }
function ChatBubble({ message }: { message: UIMessage }) { function ChatBubble({ message }: { message: UIMessage }) {
@ -74,11 +94,17 @@ function ChatBubble({ message }: { message: UIMessage }) {
<Sparkles className="size-4" /> <Sparkles className="size-4" />
</div> </div>
<div className="min-w-0 flex-1 space-y-2 pt-0.5 text-sm"> <div className="min-w-0 flex-1 space-y-2 pt-0.5 text-sm">
{message.parts.map((part, index) => {message.parts.map((part, index) => {
part.type === "text" && part.text.trim() ? ( if (part.type === "text") {
return part.text.trim() ? (
<Markdown key={index}>{part.text}</Markdown> <Markdown key={index}>{part.text}</Markdown>
) : null, ) : null;
)} }
if (part.type.startsWith("tool-")) {
return <ToolBadge key={index} part={part} />;
}
return null;
})}
</div> </div>
</div> </div>
); );
@ -168,12 +194,14 @@ export function OnboardingChatConversation({
(question) => !usedSuggestions.includes(question), (question) => !usedSuggestions.includes(question),
); );
// Show the typing indicator from the moment the user sends until the // Show the typing indicator from the moment the user sends until the
// assistant's reply has visible text — covers the "submitted" wait, when the // assistant's reply shows something — covers the "submitted" wait (last
// last message is still the user's own (so it can't gate on assistant text). // message is still the user's own) and the gap before any text or tool badge
// renders. Once a tool badge is in flight, it carries the progress, so the
// dots would just double up.
const showTyping = const showTyping =
isBusy && isBusy &&
(lastMessage?.role !== "assistant" || !messageHasText(lastMessage)); (lastMessage?.role !== "assistant" ||
const toolLabel = activeToolLabel(lastMessage, domain); !messageHasVisibleContent(lastMessage));
const showSuggestions = const showSuggestions =
remainingSuggestions.length > 0 && remainingSuggestions.length > 0 &&
!isBusy && !isBusy &&
@ -219,9 +247,6 @@ export function OnboardingChatConversation({
<Sparkles className="size-4" /> <Sparkles className="size-4" />
</div> </div>
<div className="flex items-center gap-2 pt-2 text-base-content/40"> <div className="flex items-center gap-2 pt-2 text-base-content/40">
{toolLabel ? (
<span className="text-sm">{toolLabel}</span>
) : null}
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" /> <span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" /> <span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />

View File

@ -3,6 +3,7 @@ import {
type LabsKeywordDataItem, type LabsKeywordDataItem,
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import type { CreditFeature } from "@/shared/billing-credit-features";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import { createDataforseoClient } from "@/server/lib/dataforseo";
import { import {
normalizeIntent, normalizeIntent,
@ -18,6 +19,9 @@ type FetchResearchRowsParams = {
resultLimit: number; resultLimit: number;
source: KeywordSource; source: KeywordSource;
includeClickstreamData?: boolean; includeClickstreamData?: boolean;
// Attribute the DataForSEO spend to a specific feature (e.g. "onboarding");
// defaults to the path-derived feature when omitted.
creditFeature?: CreditFeature;
}; };
function mapKeywordDataItems(items: LabsKeywordDataItem[]): EnrichedKeyword[] { function mapKeywordDataItems(items: LabsKeywordDataItem[]): EnrichedKeyword[] {
@ -106,6 +110,7 @@ export async function fetchGoogleAdsResearchRows(
locationCode: params.locationCode, locationCode: params.locationCode,
languageCode: params.languageCode, languageCode: params.languageCode,
limit: params.resultLimit, limit: params.resultLimit,
creditFeature: params.creditFeature,
}), }),
); );
} }
@ -121,6 +126,7 @@ async function fetchRelatedRows(
limit: params.resultLimit, limit: params.resultLimit,
depth: 3, depth: 3,
includeClickstreamData: params.includeClickstreamData, includeClickstreamData: params.includeClickstreamData,
creditFeature: params.creditFeature,
}); });
// Related items wrap the keyword payload one level deeper; unwrap and reuse // Related items wrap the keyword payload one level deeper; unwrap and reuse
@ -150,6 +156,7 @@ export async function fetchResearchRowsBySource(
languageCode: params.languageCode, languageCode: params.languageCode,
limit: params.resultLimit, limit: params.resultLimit,
includeClickstreamData: params.includeClickstreamData, includeClickstreamData: params.includeClickstreamData,
creditFeature: params.creditFeature,
}), }),
); );
} }
@ -161,6 +168,7 @@ export async function fetchResearchRowsBySource(
languageCode: params.languageCode, languageCode: params.languageCode,
limit: params.resultLimit, limit: params.resultLimit,
includeClickstreamData: params.includeClickstreamData, includeClickstreamData: params.includeClickstreamData,
creditFeature: params.creditFeature,
}), }),
); );
} }

View File

@ -1,5 +1,6 @@
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import type { CreditFeature } from "@/shared/billing-credit-features";
import { import {
CACHE_TTL, CACHE_TTL,
buildCacheKey, buildCacheKey,
@ -95,6 +96,7 @@ async function fetchRowsFromSource(
input: ResearchKeywordsInput, input: ResearchKeywordsInput,
seedKeyword: string, seedKeyword: string,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
): Promise<EnrichedKeyword[]> { ): Promise<EnrichedKeyword[]> {
return fetchResearchRowsBySource( return fetchResearchRowsBySource(
{ {
@ -104,6 +106,7 @@ async function fetchRowsFromSource(
languageCode: input.languageCode, languageCode: input.languageCode,
resultLimit: input.resultLimit, resultLimit: input.resultLimit,
includeClickstreamData: input.clickstream, includeClickstreamData: input.clickstream,
creditFeature,
}, },
billingCustomer, billingCustomer,
); );
@ -113,6 +116,7 @@ async function fetchAutoRows(
input: ResearchKeywordsInput, input: ResearchKeywordsInput,
seedKeyword: string, seedKeyword: string,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
): Promise<ResearchResult> { ): Promise<ResearchResult> {
const attempts: SourceAttempt[] = []; const attempts: SourceAttempt[] = [];
let lastSource: KeywordSource = "related"; let lastSource: KeywordSource = "related";
@ -125,6 +129,7 @@ async function fetchAutoRows(
input, input,
seedKeyword, seedKeyword,
billingCustomer, billingCustomer,
creditFeature,
); );
for (const row of rows) { for (const row of rows) {
if (accumulatedRows.length >= input.resultLimit) break; if (accumulatedRows.length >= input.resultLimit) break;
@ -173,6 +178,7 @@ async function fetchGoogleAdsRows(
input: ResearchKeywordsInput, input: ResearchKeywordsInput,
seedKeyword: string, seedKeyword: string,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
): Promise<ResearchResult> { ): Promise<ResearchResult> {
const rows = await fetchGoogleAdsResearchRows( const rows = await fetchGoogleAdsResearchRows(
{ {
@ -180,6 +186,7 @@ async function fetchGoogleAdsRows(
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
resultLimit: input.resultLimit, resultLimit: input.resultLimit,
creditFeature,
}, },
billingCustomer, billingCustomer,
); );
@ -207,12 +214,14 @@ async function fetchManualRows(
input: ResearchKeywordsInput, input: ResearchKeywordsInput,
seedKeyword: string, seedKeyword: string,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
): Promise<ResearchResult> { ): Promise<ResearchResult> {
const rows = await fetchRowsFromSource( const rows = await fetchRowsFromSource(
mode, mode,
input, input,
seedKeyword, seedKeyword,
billingCustomer, billingCustomer,
creditFeature,
); );
const attempt: SourceAttempt = { const attempt: SourceAttempt = {
source: mode, source: mode,
@ -276,6 +285,7 @@ function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
export async function research( export async function research(
input: ResearchKeywordsInput, input: ResearchKeywordsInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
): Promise<ResearchResult> { ): Promise<ResearchResult> {
const uniqueKeywords = [ const uniqueKeywords = [
...new Set(input.keywords.map(normalizeKeyword)), ...new Set(input.keywords.map(normalizeKeyword)),
@ -314,14 +324,25 @@ export async function research(
const result = const result =
provider === "google_ads" provider === "google_ads"
? await fetchGoogleAdsRows(effectiveInput, seedKeyword, billingCustomer) ? await fetchGoogleAdsRows(
effectiveInput,
seedKeyword,
billingCustomer,
creditFeature,
)
: mode === "auto" : mode === "auto"
? await fetchAutoRows(effectiveInput, seedKeyword, billingCustomer) ? await fetchAutoRows(
effectiveInput,
seedKeyword,
billingCustomer,
creditFeature,
)
: await fetchManualRows( : await fetchManualRows(
mode, mode,
effectiveInput, effectiveInput,
seedKeyword, seedKeyword,
billingCustomer, billingCustomer,
creditFeature,
); );
await setCached(cacheKey, result, CACHE_TTL.researchResult); await setCached(cacheKey, result, CACHE_TTL.researchResult);

View File

@ -15,6 +15,7 @@ import { AppError } from "@/server/lib/errors";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { readSite } from "@/server/features/onboarding/scrape"; import { readSite } from "@/server/features/onboarding/scrape";
import { DomainService } from "@/server/features/domain/services/DomainService"; import { DomainService } from "@/server/features/domain/services/DomainService";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { getOnboardingModel } from "@/server/lib/openrouter"; import { getOnboardingModel } from "@/server/lib/openrouter";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { import {
@ -45,9 +46,16 @@ function buildSystemPrompt(domain: string | null): string {
"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.", "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 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.", "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.", "This chat is the free onboarding preview: the user hasn't upgraded yet. Here you can answer questions and analyze their site with your tools, but they can't act inside OpenSEO yet — connecting Google Search Console, rank tracking, content tools, and the full research workflows all unlock on the paid plan. In ANY reply, you may describe what OpenSEO will do for them after they upgrade, but never tell them to do those things now and never hand them a to-do list of off-platform SEO work. Be direct that these unlock on the paid plan, but do not hard-sell.",
"You have tools to research the user's own site: read_website reads their pages as text, and get_seo_metrics returns their estimated organic traffic, ranking-keyword count, and the keywords they already rank for. Use them whenever the user asks you to analyze their site, recommend an SEO strategy, or for any site-specific advice. read_website is always available; get_seo_metrics may report it's unavailable for brand-new sites or unsupported markets — if so, work from the site content and say rankings aren't available yet. Never invent metrics you weren't given by a tool.", "You have three tools to analyze THIS user's own site. Use them whenever the user asks you to analyze their site, recommend a strategy, or for any site-specific advice. Never state a metric, search volume, or keyword difficulty you did not get from a tool.",
"When the user asks for a strategy, recommendations, or an analysis of their site, first gather data with the tools, then write a concise, practical, honest strategy specific to THIS site (never generic) in Markdown with these sections: '## Positioning' (one paragraph on what the site does and how it should position itself in search); '## Themes' (3-5 content/topic themes worth owning, each a bullet with a one-line rationale); '## Target keywords' (a short Markdown table of starter keywords with columns Keyword | Why it fits — prefer and mark keywords they already rank for; if the site is brand new with no rankings, say so plainly and propose keywords from the content); '## Do this next' (a numbered list of 3-5 concrete next actions). Keep the whole strategy under ~400 words.", "- read_website: reads their pages as plain text. Always available.",
"- get_seo_metrics: their estimated organic traffic, ranking-keyword count, and the keywords they already rank for (each with real search volume and difficulty). May report it's unavailable for brand-new sites or unsupported markets.",
"- research_keywords: given one seed topic from their site, returns related keywords each with real monthly search volume and difficulty (KD). Use it to ground keyword suggestions in real data — especially when get_seo_metrics shows no rankings. Seed it with the site's primary topic; call it again only for a clearly distinct second theme.",
"When the user asks for a strategy, recommendations, or an analysis of their site, first gather data with the tools, then write a concise, honest strategy specific to THIS site (never generic) in Markdown with exactly these sections, under ~350 words total:",
"'## 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 with columns Keyword | Volume | KD | Why it fits. Every keyword, and its Volume and KD, must come from get_seo_metrics or research_keywords — never invent, estimate, or leave these numbers blank. Mark keywords they already rank for. If you genuinely could not get keyword data for their market, say so in one line instead of showing a table with made-up numbers.",
"Close with a single short sentence offering to go deeper on any theme or keyword — not a 'next steps' or homework list.",
domain domain
? `The user's website is ${domain}.` ? `The user's website is ${domain}.`
: "If you need the user's website before answering, ask for it briefly.", : "If you need the user's website before answering, ask for it briefly.",
@ -96,7 +104,14 @@ export class OnboardingChatAgent extends AIChatAgent {
const billingCustomer = { const billingCustomer = {
// The org is the Autumn customer; userId is only an analytics distinctId. // The org is the Autumn customer; userId is only an analytics distinctId.
userId: organizationId, userId: organizationId,
userEmail: "", // The DO only knows the org/project, not the user, so it has no real email
// to attach. The org's Autumn customer is already created with the real
// email by the Worker's authorize step before any message reaches here, so
// this placeholder is only ever seen by a get-on-existing (never persisted)
// — Autumn rejects an empty string. Mirrors the scheduled rank-check job's
// user-less metering, but onboarding-specific so it's identifiable in
// Autumn logs.
userEmail: "system-onboarding@openseo.so",
organizationId, organizationId,
projectId: project.id, projectId: project.id,
}; };
@ -218,6 +233,7 @@ export class OnboardingChatAgent extends AIChatAgent {
}; };
} }
try {
// Fetch the overview and ranked keywords in parallel so the tool // Fetch the overview and ranked keywords in parallel so the tool
// doesn't block on the two DataForSEO calls in series. Trade-off: // doesn't block on the two DataForSEO calls in series. Trade-off:
// this always issues the (metered) ranked-keywords call, even for // this always issues the (metered) ranked-keywords call, even for
@ -264,13 +280,88 @@ export class OnboardingChatAgent extends AIChatAgent {
organicKeywords: overview.organicKeywords, organicKeywords: overview.organicKeywords,
rankedKeywords, rankedKeywords,
}; };
} catch (error) {
console.error("[onboarding] get_seo_metrics failed", {
domain: project.domain,
locationCode: project.locationCode,
languageCode: project.languageCode,
error,
});
throw error;
}
},
}),
research_keywords: tool({
description:
"Research real keyword ideas for the user's site. Given one seed topic drawn from their content, returns related keywords each with monthly search volume, keyword difficulty (KD), and intent. Use to ground keyword suggestions in real data, especially when the site has no rankings yet.",
inputSchema: z.object({
seed: z
.string()
.min(1)
.describe(
"A short seed topic or phrase from the site's content (e.g. 'agentless PAM').",
),
}),
execute: async ({ seed }) => {
if (!project.domain) {
throw new AppError(
"VALIDATION_ERROR",
"Set a website domain first",
);
}
try {
const researchResult = await KeywordResearchService.research(
{
projectId: project.id,
keywords: [seed],
locationCode: project.locationCode,
languageCode: project.languageCode,
resultLimit: 150,
// One source (keyword_ideas) keeps onboarding spend to a single
// DataForSEO call; research() routes unsupported markets to the
// Google Ads fallback automatically.
mode: "ideas",
clickstream: false,
},
billingCustomer,
"onboarding",
);
const keywords = researchResult.rows
// Keep only keywords with a real volume — the strategy table
// shows volume + KD, so a null-volume row can't be grounded.
.filter((row) => row.searchVolume != null)
.toSorted(
(a, b) => (b.searchVolume ?? 0) - (a.searchVolume ?? 0),
)
.map((row) => ({
keyword: row.keyword,
searchVolume: row.searchVolume,
keywordDifficulty: row.keywordDifficulty,
intent: row.intent,
}));
return { available: keywords.length > 0, keywords };
} catch (error) {
console.error("[onboarding] research_keywords failed", {
domain: project.domain,
seed,
locationCode: project.locationCode,
languageCode: project.languageCode,
error,
});
throw error;
}
}, },
}), }),
} as ToolSet, } as ToolSet,
}); });
return result.toUIMessageStreamResponse({ return result.toUIMessageStreamResponse({
onError: () => "The assistant hit an error. Please try again.", onError: (error) => {
console.error("[onboarding] chat stream error", error);
return "The assistant hit an error. Please try again.";
},
}); });
} }
} }