Onboarding chat: MiniMax M3 + market-research tools + UX polish (#296)
This commit is contained in:
parent
770005e2b9
commit
90a04d1845
15
README.md
15
README.md
@ -310,15 +310,18 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
|
|||||||
|
|
||||||
### 1) Rank tracking
|
### 1) Rank tracking
|
||||||
|
|
||||||
There are in-app estimates for this since its dependent on the settings you select.
|
Rank tracking is fully configurable, and there are live in-app estimates since cost depends on the settings you select: number of keywords, devices, how many SERP pages deep you check, and how often it runs (weekly or daily).
|
||||||
|
|
||||||
$2/month example:
|
Scheduled checks run through DataForSEO's task queue, which is significantly cheaper than live SERP lookups.
|
||||||
|
|
||||||
- 50 keywords
|
~$1/month example:
|
||||||
- 1 device (Mobile or Desktop)
|
|
||||||
- Search 5 pages deep.
|
|
||||||
|
|
||||||
Searching ten pages deep costs 8x more than one page. Tracking both devices costs 2x more.
|
- 100 keywords
|
||||||
|
- 1 device type (Mobile or Desktop)
|
||||||
|
- Search 5 pages deep
|
||||||
|
- Weekly schedule
|
||||||
|
|
||||||
|
Searching ten pages deep costs about 8x more than one page, tracking both device types costs 2x more, and a daily schedule costs about 7x a weekly one.
|
||||||
|
|
||||||
### 2) Site audit
|
### 2) Site audit
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,13 @@ 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, Loader2, Check, AlertTriangle } from "lucide-react";
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
|
} 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";
|
||||||
@ -23,10 +29,46 @@ function messageHasVisibleContent(message: UIMessage): boolean {
|
|||||||
return message.parts.some(
|
return message.parts.some(
|
||||||
(part) =>
|
(part) =>
|
||||||
(part.type === "text" && part.text.trim().length > 0) ||
|
(part.type === "text" && part.text.trim().length > 0) ||
|
||||||
|
(part.type === "reasoning" && part.text.trim().length > 0) ||
|
||||||
part.type.startsWith("tool-"),
|
part.type.startsWith("tool-"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapsible "thinking" block for the model's reasoning stream. Collapsed by
|
||||||
|
// default so the chain-of-thought doesn't bury the answer; while it's still
|
||||||
|
// streaming it doubles as the progress indicator ("Thinking…" + spinner).
|
||||||
|
function ReasoningBlock({
|
||||||
|
part,
|
||||||
|
}: {
|
||||||
|
part: Extract<UIMessage["parts"][number], { type: "reasoning" }>;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const isStreaming = part.state === "streaming";
|
||||||
|
return (
|
||||||
|
<div className="text-base-content/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((open) => !open)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs hover:text-base-content/80"
|
||||||
|
>
|
||||||
|
{isStreaming ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight
|
||||||
|
className={`size-3 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{isStreaming ? "Thinking…" : "Thought process"}</span>
|
||||||
|
</button>
|
||||||
|
{expanded ? (
|
||||||
|
<div className="mt-1.5 whitespace-pre-wrap border-l-2 border-base-300 pl-3 text-xs text-base-content/50">
|
||||||
|
{part.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Friendly labels for each tool Sam can run, so the chat shows what it's doing
|
// Friendly labels for each tool Sam can run, so the chat shows what it's doing
|
||||||
// rather than going silent while it gathers site data. `running` shows while the
|
// rather than going silent while it gathers site data. `running` shows while the
|
||||||
// call is in flight; `done` stays as a persistent badge once it finishes.
|
// call is in flight; `done` stays as a persistent badge once it finishes.
|
||||||
@ -40,6 +82,26 @@ const TOOL_LABELS: Record<string, { running: string; done: string }> = {
|
|||||||
running: "Researching keywords",
|
running: "Researching keywords",
|
||||||
done: "Keyword research",
|
done: "Keyword research",
|
||||||
},
|
},
|
||||||
|
"tool-get_domain_overview": {
|
||||||
|
running: "Analyzing domain",
|
||||||
|
done: "Domain overview",
|
||||||
|
},
|
||||||
|
"tool-get_serp_results": {
|
||||||
|
running: "Checking search results",
|
||||||
|
done: "Search results",
|
||||||
|
},
|
||||||
|
"tool-find_serp_competitors": {
|
||||||
|
running: "Finding competitors",
|
||||||
|
done: "Competitors",
|
||||||
|
},
|
||||||
|
"tool-get_competitor_keywords": {
|
||||||
|
running: "Analyzing competitor",
|
||||||
|
done: "Competitor keywords",
|
||||||
|
},
|
||||||
|
"tool-get_backlinks_overview": {
|
||||||
|
running: "Checking backlinks",
|
||||||
|
done: "Backlinks overview",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// A small inline badge for one tool call, rendered in document order inside the
|
// A small inline badge for one tool call, rendered in document order inside the
|
||||||
@ -95,6 +157,11 @@ function ChatBubble({ message }: { message: UIMessage }) {
|
|||||||
</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) => {
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
return part.text.trim() ? (
|
||||||
|
<ReasoningBlock key={index} part={part} />
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
return part.text.trim() ? (
|
return part.text.trim() ? (
|
||||||
<Markdown key={index}>{part.text}</Markdown>
|
<Markdown key={index}>{part.text}</Markdown>
|
||||||
@ -111,15 +178,18 @@ function ChatBubble({ message }: { message: UIMessage }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SUGGESTED_QUESTIONS = [
|
const SUGGESTED_QUESTIONS = [
|
||||||
"How does OpenSEO help me get more traffic?",
|
"How will OpenSEO help me get more traffic?",
|
||||||
"Why is OpenSEO better than Claude?",
|
"Compare OpenSEO and Claude",
|
||||||
"What do I get after I upgrade?",
|
"What do I get after I upgrade?",
|
||||||
"How does Google Search Console work in OpenSEO?",
|
"How does the Google Search Console integration work?",
|
||||||
|
"Right fit for consultants and agencies?",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Offered as a highlighted chip only when the user hasn't already asked for
|
// Highlighted (primary) chips shown first, before the general questions.
|
||||||
// their strategy via the welcome CTA. Clicking it prompts Sam to draft/show it.
|
// STRATEGY_SUGGESTION drops out once the user has asked for their strategy.
|
||||||
const STRATEGY_SUGGESTION = "What do you recommend for my site?";
|
const STRATEGY_SUGGESTION = "What do you recommend for my site?";
|
||||||
|
const COMPETITOR_SUGGESTION = "Compare against my competitors";
|
||||||
|
const PRIMARY_SUGGESTIONS = [STRATEGY_SUGGESTION, COMPETITOR_SUGGESTION];
|
||||||
|
|
||||||
export function OnboardingChatConversation({
|
export function OnboardingChatConversation({
|
||||||
projectId,
|
projectId,
|
||||||
@ -187,9 +257,11 @@ export function OnboardingChatConversation({
|
|||||||
}, [messages, status]);
|
}, [messages, status]);
|
||||||
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
const lastMessage = messages[messages.length - 1];
|
||||||
const suggestionPool = strategyRequested
|
const suggestionPool = [
|
||||||
? SUGGESTED_QUESTIONS
|
...(strategyRequested ? [] : [STRATEGY_SUGGESTION]),
|
||||||
: [STRATEGY_SUGGESTION, ...SUGGESTED_QUESTIONS];
|
COMPETITOR_SUGGESTION,
|
||||||
|
...SUGGESTED_QUESTIONS,
|
||||||
|
];
|
||||||
const remainingSuggestions = suggestionPool.filter(
|
const remainingSuggestions = suggestionPool.filter(
|
||||||
(question) => !usedSuggestions.includes(question),
|
(question) => !usedSuggestions.includes(question),
|
||||||
);
|
);
|
||||||
@ -202,11 +274,12 @@ export function OnboardingChatConversation({
|
|||||||
isBusy &&
|
isBusy &&
|
||||||
(lastMessage?.role !== "assistant" ||
|
(lastMessage?.role !== "assistant" ||
|
||||||
!messageHasVisibleContent(lastMessage));
|
!messageHasVisibleContent(lastMessage));
|
||||||
|
// Show the chips up front (before the first message) and after each assistant
|
||||||
|
// reply, but not while a reply is mid-flight.
|
||||||
const showSuggestions =
|
const showSuggestions =
|
||||||
remainingSuggestions.length > 0 &&
|
remainingSuggestions.length > 0 &&
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
messages.length > 0 &&
|
(messages.length === 0 || lastMessage?.role === "assistant");
|
||||||
lastMessage?.role === "assistant";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
@ -225,16 +298,6 @@ export function OnboardingChatConversation({
|
|||||||
checkoutError={checkoutError}
|
checkoutError={checkoutError}
|
||||||
isStartingCheckout={isStartingCheckout}
|
isStartingCheckout={isStartingCheckout}
|
||||||
onUpgrade={() => void startCheckout()}
|
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) => (
|
{messages.map((message) => (
|
||||||
@ -273,7 +336,7 @@ export function OnboardingChatConversation({
|
|||||||
{showSuggestions ? (
|
{showSuggestions ? (
|
||||||
<SuggestedQuestions
|
<SuggestedQuestions
|
||||||
questions={remainingSuggestions}
|
questions={remainingSuggestions}
|
||||||
primaryQuestion={STRATEGY_SUGGESTION}
|
primaryQuestions={PRIMARY_SUGGESTIONS}
|
||||||
onSelect={(question) => {
|
onSelect={(question) => {
|
||||||
setUsedSuggestions((current) =>
|
setUsedSuggestions((current) =>
|
||||||
current.includes(question)
|
current.includes(question)
|
||||||
|
|||||||
@ -12,17 +12,17 @@ const DISCORD_URL = "https://discord.gg/c9uGs3cFXr";
|
|||||||
|
|
||||||
export function SuggestedQuestions({
|
export function SuggestedQuestions({
|
||||||
questions,
|
questions,
|
||||||
primaryQuestion,
|
primaryQuestions = [],
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
questions: string[];
|
questions: string[];
|
||||||
primaryQuestion?: string;
|
primaryQuestions?: string[];
|
||||||
onSelect: (question: string) => void;
|
onSelect: (question: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="ml-10 flex flex-wrap gap-2">
|
<div className="ml-10 flex flex-wrap gap-2">
|
||||||
{questions.map((question) =>
|
{questions.map((question) =>
|
||||||
question === primaryQuestion ? (
|
primaryQuestions.includes(question) ? (
|
||||||
<button
|
<button
|
||||||
key={question}
|
key={question}
|
||||||
type="button"
|
type="button"
|
||||||
@ -52,17 +52,11 @@ export function WelcomeMessage({
|
|||||||
checkoutError,
|
checkoutError,
|
||||||
isStartingCheckout,
|
isStartingCheckout,
|
||||||
onUpgrade,
|
onUpgrade,
|
||||||
onAskAboutOpenSeo,
|
|
||||||
onProposeStrategy,
|
|
||||||
disableActions,
|
|
||||||
}: {
|
}: {
|
||||||
domain: string;
|
domain: string;
|
||||||
checkoutError: string | null;
|
checkoutError: string | null;
|
||||||
isStartingCheckout: boolean;
|
isStartingCheckout: boolean;
|
||||||
onUpgrade: () => void;
|
onUpgrade: () => void;
|
||||||
onAskAboutOpenSeo: () => void;
|
|
||||||
onProposeStrategy: () => void;
|
|
||||||
disableActions: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@ -95,29 +89,11 @@ export function WelcomeMessage({
|
|||||||
<p>
|
<p>
|
||||||
Want me to analyze{" "}
|
Want me to analyze{" "}
|
||||||
<span className="font-medium text-base-content">{domain}</span> and
|
<span className="font-medium text-base-content">{domain}</span> and
|
||||||
draft a strategy, or do you have questions first?
|
draft a strategy, or do you have questions first? Pick one below to
|
||||||
|
get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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="font-medium">Want Sam to keep going?</p>
|
||||||
<p className="mt-0.5 text-base-content/70">
|
<p className="mt-0.5 text-base-content/70">
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
type TopPagesPageServiceInput,
|
type TopPagesPageServiceInput,
|
||||||
} from "@/server/features/backlinks/services/backlinksServiceData";
|
} from "@/server/features/backlinks/services/backlinksServiceData";
|
||||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
|
import type { CreditFeature } from "@/shared/billing-credit-features";
|
||||||
|
|
||||||
const defaultCache: BacklinksCache = {
|
const defaultCache: BacklinksCache = {
|
||||||
get: getCached,
|
get: getCached,
|
||||||
@ -39,12 +40,22 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
|
|||||||
async profileOverview(
|
async profileOverview(
|
||||||
input: BacklinksLookupInput,
|
input: BacklinksLookupInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
|
// Lets a caller (e.g. onboarding) attribute the spend to its own credit
|
||||||
|
// feature. Applied to the DataForSEO calls, not the cache key, so cached
|
||||||
|
// results stay shared across callers.
|
||||||
|
creditFeature?: CreditFeature,
|
||||||
) {
|
) {
|
||||||
const cacheKey = await buildCacheKey("backlinks:overview", {
|
const cacheKey = await buildCacheKey("backlinks:overview", {
|
||||||
...buildTargetCacheInput(input, billingCustomer),
|
...buildTargetCacheInput(input, billingCustomer),
|
||||||
});
|
});
|
||||||
|
|
||||||
return profileBacklinksOverview(cache, cacheKey, input, billingCustomer);
|
return profileBacklinksOverview(
|
||||||
|
cache,
|
||||||
|
cacheKey,
|
||||||
|
input,
|
||||||
|
billingCustomer,
|
||||||
|
creditFeature,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async profileBacklinksPage(
|
async profileBacklinksPage(
|
||||||
input: BacklinksRowsPageServiceInput,
|
input: BacklinksRowsPageServiceInput,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
|
import type { CreditFeature } from "@/shared/billing-credit-features";
|
||||||
import {
|
import {
|
||||||
createDataforseoClient,
|
createDataforseoClient,
|
||||||
normalizeBacklinksTarget,
|
normalizeBacklinksTarget,
|
||||||
@ -74,6 +75,7 @@ export async function profileBacklinksOverview(
|
|||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
input: BacklinksLookupInput,
|
input: BacklinksLookupInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
|
creditFeature?: CreditFeature,
|
||||||
): Promise<BacklinksOverviewProfile> {
|
): Promise<BacklinksOverviewProfile> {
|
||||||
const cached = backlinksOverviewCacheSchema.safeParse(
|
const cached = backlinksOverviewCacheSchema.safeParse(
|
||||||
await cache.get(cacheKey),
|
await cache.get(cacheKey),
|
||||||
@ -93,11 +95,15 @@ export async function profileBacklinksOverview(
|
|||||||
const dateRange = buildBacklinksDateRange(now);
|
const dateRange = buildBacklinksDateRange(now);
|
||||||
|
|
||||||
const [summary, history] = await Promise.all([
|
const [summary, history] = await Promise.all([
|
||||||
dataforseo.backlinks.summary({ target: normalizedTarget.apiTarget }),
|
dataforseo.backlinks.summary({
|
||||||
|
target: normalizedTarget.apiTarget,
|
||||||
|
creditFeature,
|
||||||
|
}),
|
||||||
normalizedTarget.scope === "domain"
|
normalizedTarget.scope === "domain"
|
||||||
? dataforseo.backlinks.history({
|
? dataforseo.backlinks.history({
|
||||||
target: normalizedTarget.apiTarget,
|
target: normalizedTarget.apiTarget,
|
||||||
...dateRange,
|
...dateRange,
|
||||||
|
creditFeature,
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -5,17 +5,13 @@ import {
|
|||||||
createUIMessageStreamResponse,
|
createUIMessageStreamResponse,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
streamText,
|
streamText,
|
||||||
tool,
|
|
||||||
type StreamTextOnFinishCallback,
|
type StreamTextOnFinishCallback,
|
||||||
type ToolSet,
|
type ToolSet,
|
||||||
} from "ai";
|
} from "ai";
|
||||||
import type { OnChatMessageOptions } from "@cloudflare/ai-chat";
|
import type { OnChatMessageOptions } from "@cloudflare/ai-chat";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
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 { buildOnboardingTools } from "@/server/features/onboarding/onboardingChatTools";
|
||||||
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 {
|
||||||
@ -24,7 +20,6 @@ import {
|
|||||||
trackUsageCreditSpend,
|
trackUsageCreditSpend,
|
||||||
} from "@/server/billing/subscription";
|
} from "@/server/billing/subscription";
|
||||||
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
|
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
|
||||||
import { isLabsLocationCode, LOCATIONS } from "@/shared/keyword-locations";
|
|
||||||
import openSeoFactSheet from "@/server/features/onboarding/openseo-fact-sheet.md?raw";
|
import openSeoFactSheet from "@/server/features/onboarding/openseo-fact-sheet.md?raw";
|
||||||
|
|
||||||
// OpenRouter (with usage accounting on) reports the real USD cost of each
|
// OpenRouter (with usage accounting on) reports the real USD cost of each
|
||||||
@ -41,22 +36,31 @@ function openRouterCostUsd(providerMetadata: unknown): number {
|
|||||||
function buildSystemPrompt(domain: string | null): string {
|
function buildSystemPrompt(domain: string | null): string {
|
||||||
return [
|
return [
|
||||||
"You are Sam, the SEO onboarding agent inside OpenSEO. Introduce yourself as Sam if the user asks who you are.",
|
"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.",
|
"Write for a founder who is new to SEO, not an expert: default to short, scannable, persuasive answers. Lead with a one-sentence direct answer, then at most 2-3 short paragraphs OR a few bullets — aim for under ~150 words unless the user explicitly asks you to go deep. Keep paragraphs to 2-3 sentences, use bullets for any list, and bold only the few words that carry the point. Prefer bullets over a wall of prose.",
|
||||||
|
"Explain SEO jargon in plain language the first time it comes up (e.g. topical authority, head terms, KD/keyword difficulty), and tie each point back to a concrete outcome the user cares about — more of the right visitors, less wasted effort. Be persuasive through specifics and honesty, never hype or overpromising.",
|
||||||
|
"Write in plain prose and Markdown. Do not use decorative emoji or symbol markers (✅, ✔, 🚀, etc.) in your responses, including inside tables — they make replies look cluttered. Convey status and emphasis with words.",
|
||||||
"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.",
|
"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.",
|
"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, keep the same short, scannable format: open with one plain-language sentence on how traffic actually grows (earning topical authority in Google and AI answers — i.e. becoming a trusted source on a focused set of topics), then a few bullets tying OpenSEO's role to that path: find winnable keywords, focus early topics, expand into broader searches, track what moves. Do not write a multi-paragraph essay and do not answer as only a feature list.",
|
||||||
"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.",
|
"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.",
|
||||||
"Keep recommendations inside OpenSEO; don't point users to other SEO tools.",
|
"Keep recommendations inside OpenSEO; don't point users to other SEO tools.",
|
||||||
"When a request is beyond your preview tools, don't conclude OpenSEO can't do it — describe what the full product does per the fact sheet, and don't claim capabilities the fact sheet doesn't list.",
|
"When a request is beyond your preview tools, don't conclude OpenSEO can't do it — describe what the full product does per the fact sheet, and don't claim capabilities the fact sheet doesn't list.",
|
||||||
"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.",
|
"You have tools to pull real search data. Never state a metric, search volume, keyword difficulty, ranking, or competitor figure you did not get from a tool.",
|
||||||
|
"Core tools for THIS user's own site — use these freely whenever the user asks you to analyze their site, recommend a strategy, or for any site-specific advice:",
|
||||||
"- read_website: reads their pages as plain text. Always available.",
|
"- 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.",
|
"- 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.",
|
"- 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.",
|
||||||
|
"Market & competitor tools — these cost more credits, so use them SPARINGLY and only when the user's question is specifically about competitors, the live SERP, or backlinks. Do NOT call them just to enrich a routine strategy, and never call more than one or two per reply. The core site tools above answer most questions on their own.",
|
||||||
|
"- get_domain_overview: organic footprint (traffic, keyword count, backlinks) for ANY domain. Use only to compare the user against a competitor they name, or one clear market leader — not a roster of competitors.",
|
||||||
|
"- get_serp_results: live Google results for 1-3 keywords, showing who ranks on page one. Use only when the user wants to see the actual SERP for a specific term.",
|
||||||
|
"- find_serp_competitors: given 2-5 of the user's target keywords, returns the domains competing with them. Use only when the user asks who their SEO competitors are; call it once.",
|
||||||
|
"- get_competitor_keywords: the keywords one competitor domain ranks for, for gap analysis. Use only when the user wants to know what a specific competitor wins; limit to one or two domains total.",
|
||||||
|
"- get_backlinks_overview: backlinks/referring-domain counts for a domain (the most expensive tool). Use only when the user explicitly asks about backlinks or site authority.",
|
||||||
"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:",
|
"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.",
|
"'## 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.",
|
"'## 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.",
|
"'## Target keywords' — a short Markdown table with columns Keyword | Volume | KD | Why it fits. Every keyword, and its Volume and KD, must come from a tool (get_seo_metrics, research_keywords, or get_competitor_keywords) — never invent, estimate, or leave these numbers blank. For keywords they already rank for, note it plainly in the 'Why it fits' column (e.g. 'you rank #17') — do not add emoji or symbol markers to the keyword. 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.",
|
"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}.`
|
||||||
@ -117,7 +121,6 @@ export class OnboardingChatAgent extends AIChatAgent {
|
|||||||
organizationId,
|
organizationId,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
};
|
};
|
||||||
const metering = { creditFeature: "onboarding" as const };
|
|
||||||
|
|
||||||
// In hosted mode, gate every turn on billing: past the free-question cap the
|
// In hosted mode, gate every turn on billing: past the free-question cap the
|
||||||
// user must have paid access, and either way the org must still have credits
|
// user must have paid access, and either way the org must still have credits
|
||||||
@ -152,16 +155,18 @@ export class OnboardingChatAgent extends AIChatAgent {
|
|||||||
|
|
||||||
const model = await getOnboardingModel();
|
const model = await getOnboardingModel();
|
||||||
|
|
||||||
// `tools` is widened to ToolSet so streamText infers a generic tool set;
|
|
||||||
// that makes its onFinish event assignable to the
|
|
||||||
// StreamTextOnFinishCallback<ToolSet> we forward for message persistence.
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
system: buildSystemPrompt(project.domain),
|
system: buildSystemPrompt(project.domain),
|
||||||
messages: await convertToModelMessages(this.messages),
|
messages: await convertToModelMessages(this.messages),
|
||||||
// Cancel the (billable) LLM call if the user aborts/navigates away.
|
// Cancel the (billable) LLM call if the user aborts/navigates away.
|
||||||
abortSignal: options?.abortSignal,
|
abortSignal: options?.abortSignal,
|
||||||
maxOutputTokens: 1600,
|
// Budget shared by reasoning + visible output. Reasoning tokens (enabled
|
||||||
|
// on the model) eat into this, so it's well above what the ~350-word
|
||||||
|
// strategy needs — otherwise the answer truncates mid-table once the
|
||||||
|
// model has spent the budget thinking. It's a ceiling, not a target: the
|
||||||
|
// model only generates (and we only bill) what it actually uses.
|
||||||
|
maxOutputTokens: 4000,
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
// Meter LLM spend against the same credit pool as DataForSEO: sum the real
|
// Meter LLM spend against the same credit pool as DataForSEO: sum the real
|
||||||
// per-step cost OpenRouter reports and deduct it. Best-effort, hosted-only.
|
// per-step cost OpenRouter reports and deduct it. Best-effort, hosted-only.
|
||||||
@ -183,180 +188,7 @@ export class OnboardingChatAgent extends AIChatAgent {
|
|||||||
// Persist the assistant turn to this.messages (DO SQLite).
|
// Persist the assistant turn to this.messages (DO SQLite).
|
||||||
await onFinish(event);
|
await onFinish(event);
|
||||||
},
|
},
|
||||||
tools: {
|
tools: buildOnboardingTools({ project, billingCustomer }),
|
||||||
read_website: tool({
|
|
||||||
description:
|
|
||||||
"Read the user's own website (their pages, as plain text) to ground site-specific advice and strategy. Uses the project's saved domain.",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
execute: async () => {
|
|
||||||
if (!project.domain) {
|
|
||||||
throw new AppError(
|
|
||||||
"VALIDATION_ERROR",
|
|
||||||
"Set a website domain first",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const site = await readSite(project.domain);
|
|
||||||
if (site.blocked) {
|
|
||||||
return {
|
|
||||||
blocked: true,
|
|
||||||
pages: [],
|
|
||||||
note: "Could not read the site's pages. Ask the user to describe what they do, and keep the advice high-level.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
pages: site.pages.map((page) => ({
|
|
||||||
url: page.url,
|
|
||||||
title: page.title,
|
|
||||||
text: page.text,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
get_seo_metrics: tool({
|
|
||||||
description:
|
|
||||||
"Get search-data signal for the user's own site: estimated organic traffic, number of ranking keywords, and the keywords they already rank for (top by traffic). Use to ground strategy in real rankings. May report unavailable for brand-new sites or unsupported markets.",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
execute: async () => {
|
|
||||||
if (!project.domain) {
|
|
||||||
throw new AppError(
|
|
||||||
"VALIDATION_ERROR",
|
|
||||||
"Set a website domain first",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Domain endpoints are Labs-only, so an unsupported market gets
|
|
||||||
// content-only advice. Spend is bounded by the org's credit balance,
|
|
||||||
// already asserted for the turn.
|
|
||||||
if (!isLabsLocationCode(project.locationCode)) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
reason:
|
|
||||||
"Ranking data isn't available for this market yet. Work from the site content instead.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch the overview and ranked keywords in parallel so the tool
|
|
||||||
// doesn't block on the two DataForSEO calls in series. Trade-off:
|
|
||||||
// this always issues the (metered) ranked-keywords call, even for
|
|
||||||
// sites with no rankings where the sequential version skipped it.
|
|
||||||
const [overview, ranked] = await Promise.all([
|
|
||||||
DomainService.getOverview(
|
|
||||||
{
|
|
||||||
projectId: project.id,
|
|
||||||
domain: project.domain,
|
|
||||||
includeSubdomains: false,
|
|
||||||
locationCode: project.locationCode,
|
|
||||||
languageCode: project.languageCode,
|
|
||||||
},
|
|
||||||
billingCustomer,
|
|
||||||
metering,
|
|
||||||
),
|
|
||||||
DomainService.getSuggestedKeywords(
|
|
||||||
{
|
|
||||||
domain: project.domain,
|
|
||||||
locationCode: project.locationCode,
|
|
||||||
languageCode: project.languageCode,
|
|
||||||
organizationId,
|
|
||||||
projectId: project.id,
|
|
||||||
},
|
|
||||||
billingCustomer,
|
|
||||||
metering,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rankedKeywords = overview.hasData
|
|
||||||
? ranked.slice(0, 20).map((kw) => ({
|
|
||||||
keyword: kw.keyword,
|
|
||||||
position: kw.position,
|
|
||||||
searchVolume: kw.searchVolume,
|
|
||||||
keywordDifficulty: kw.keywordDifficulty,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
market: LOCATIONS[project.locationCode] ?? "your market",
|
|
||||||
hasRankings: overview.hasData,
|
|
||||||
organicTraffic: overview.organicTraffic,
|
|
||||||
organicKeywords: overview.organicKeywords,
|
|
||||||
rankedKeywords,
|
|
||||||
};
|
|
||||||
} 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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.toUIMessageStreamResponse({
|
return result.toUIMessageStreamResponse({
|
||||||
|
|||||||
238
src/server/features/onboarding/onboardingChatTools.ts
Normal file
238
src/server/features/onboarding/onboardingChatTools.ts
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { tool, type ToolSet } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { readSite } from "@/server/features/onboarding/scrape";
|
||||||
|
import { DomainService } from "@/server/features/domain/services/DomainService";
|
||||||
|
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
|
||||||
|
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
||||||
|
import { marketTools } from "@/server/features/onboarding/onboardingMarketTools";
|
||||||
|
import { isLabsLocationCode, LOCATIONS } from "@/shared/keyword-locations";
|
||||||
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
|
|
||||||
|
// Just the project fields the tools read; the caller passes the full project row.
|
||||||
|
type OnboardingProject = {
|
||||||
|
id: string;
|
||||||
|
domain: string | null;
|
||||||
|
locationCode: number;
|
||||||
|
languageCode: string;
|
||||||
|
organizationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shared context derived once per turn and passed to each tool group.
|
||||||
|
export type ToolContext = {
|
||||||
|
project: OnboardingProject;
|
||||||
|
organizationId: string;
|
||||||
|
billingCustomer: BillingCustomerContext;
|
||||||
|
metering: { creditFeature: "onboarding" };
|
||||||
|
dfsClient: ReturnType<typeof createDataforseoClient>;
|
||||||
|
isSameDomain: (domain: unknown) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the tool set the onboarding chat agent hands to `streamText`. Split out
|
||||||
|
* of OnboardingChatAgent so the agent file (and onChatMessage) stays readable.
|
||||||
|
*
|
||||||
|
* Two tiers: the core tools analyze the user's own site; the market tools look
|
||||||
|
* at any domain and cost more credits — the system prompt tells Sam to use them
|
||||||
|
* sparingly. Every metered DataForSEO call is attributed to the "onboarding"
|
||||||
|
* credit feature. The result is widened to ToolSet so streamText infers a
|
||||||
|
* generic tool set, keeping its onFinish event assignable to the
|
||||||
|
* StreamTextOnFinishCallback<ToolSet> the agent forwards for persistence.
|
||||||
|
*/
|
||||||
|
export function buildOnboardingTools({
|
||||||
|
project,
|
||||||
|
billingCustomer,
|
||||||
|
}: {
|
||||||
|
project: OnboardingProject;
|
||||||
|
billingCustomer: BillingCustomerContext;
|
||||||
|
}): ToolSet {
|
||||||
|
// Normalized form of the user's own domain, used to drop self-matches from
|
||||||
|
// competitor results.
|
||||||
|
const ownDomain = project.domain
|
||||||
|
? project.domain.replace(/^www\./, "").toLowerCase()
|
||||||
|
: null;
|
||||||
|
const ctx: ToolContext = {
|
||||||
|
project,
|
||||||
|
organizationId: project.organizationId,
|
||||||
|
billingCustomer,
|
||||||
|
metering: { creditFeature: "onboarding" },
|
||||||
|
// Each metered call passes `creditFeature: "onboarding"` so spend lands on
|
||||||
|
// the onboarding line; the org's balance is asserted by the agent first.
|
||||||
|
dfsClient: createDataforseoClient(billingCustomer),
|
||||||
|
isSameDomain: (domain) =>
|
||||||
|
ownDomain != null &&
|
||||||
|
typeof domain === "string" &&
|
||||||
|
domain.replace(/^www\./, "").toLowerCase() === ownDomain,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...coreSiteTools(ctx), ...marketTools(ctx) } as ToolSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools that analyze the user's own site (read_website, get_seo_metrics,
|
||||||
|
// research_keywords). Used freely per the system prompt.
|
||||||
|
function coreSiteTools(ctx: ToolContext): ToolSet {
|
||||||
|
const { project, organizationId, billingCustomer, metering } = ctx;
|
||||||
|
return {
|
||||||
|
read_website: tool({
|
||||||
|
description:
|
||||||
|
"Read the user's own website (their pages, as plain text) to ground site-specific advice and strategy. Uses the project's saved domain.",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
if (!project.domain) {
|
||||||
|
throw new AppError("VALIDATION_ERROR", "Set a website domain first");
|
||||||
|
}
|
||||||
|
const site = await readSite(project.domain);
|
||||||
|
if (site.blocked) {
|
||||||
|
return {
|
||||||
|
blocked: true,
|
||||||
|
pages: [],
|
||||||
|
note: "Could not read the site's pages. Ask the user to describe what they do, and keep the advice high-level.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
blocked: false,
|
||||||
|
pages: site.pages.map((page) => ({
|
||||||
|
url: page.url,
|
||||||
|
title: page.title,
|
||||||
|
text: page.text,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
get_seo_metrics: tool({
|
||||||
|
description:
|
||||||
|
"Get search-data signal for the user's own site: estimated organic traffic, number of ranking keywords, and the keywords they already rank for (top by traffic). Use to ground strategy in real rankings. May report unavailable for brand-new sites or unsupported markets.",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
if (!project.domain) {
|
||||||
|
throw new AppError("VALIDATION_ERROR", "Set a website domain first");
|
||||||
|
}
|
||||||
|
// Domain endpoints are Labs-only, so an unsupported market gets
|
||||||
|
// content-only advice. Spend is bounded by the org's credit balance,
|
||||||
|
// already asserted for the turn.
|
||||||
|
if (!isLabsLocationCode(project.locationCode)) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason:
|
||||||
|
"Ranking data isn't available for this market yet. Work from the site content instead.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the overview and ranked keywords in parallel so the tool
|
||||||
|
// doesn't block on the two DataForSEO calls in series. Trade-off:
|
||||||
|
// this always issues the (metered) ranked-keywords call, even for
|
||||||
|
// sites with no rankings where the sequential version skipped it.
|
||||||
|
const [overview, ranked] = await Promise.all([
|
||||||
|
DomainService.getOverview(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
domain: project.domain,
|
||||||
|
includeSubdomains: false,
|
||||||
|
locationCode: project.locationCode,
|
||||||
|
languageCode: project.languageCode,
|
||||||
|
},
|
||||||
|
billingCustomer,
|
||||||
|
metering,
|
||||||
|
),
|
||||||
|
DomainService.getSuggestedKeywords(
|
||||||
|
{
|
||||||
|
domain: project.domain,
|
||||||
|
locationCode: project.locationCode,
|
||||||
|
languageCode: project.languageCode,
|
||||||
|
organizationId,
|
||||||
|
projectId: project.id,
|
||||||
|
},
|
||||||
|
billingCustomer,
|
||||||
|
metering,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rankedKeywords = overview.hasData
|
||||||
|
? ranked.slice(0, 20).map((kw) => ({
|
||||||
|
keyword: kw.keyword,
|
||||||
|
position: kw.position,
|
||||||
|
searchVolume: kw.searchVolume,
|
||||||
|
keywordDifficulty: kw.keywordDifficulty,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
market: LOCATIONS[project.locationCode] ?? "your market",
|
||||||
|
hasRankings: overview.hasData,
|
||||||
|
organicTraffic: overview.organicTraffic,
|
||||||
|
organicKeywords: overview.organicKeywords,
|
||||||
|
rankedKeywords,
|
||||||
|
};
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
240
src/server/features/onboarding/onboardingMarketTools.ts
Normal file
240
src/server/features/onboarding/onboardingMarketTools.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { tool, type ToolSet } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { DomainService } from "@/server/features/domain/services/DomainService";
|
||||||
|
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
||||||
|
import { isLabsLocationCode, LOCATIONS } from "@/shared/keyword-locations";
|
||||||
|
import type { ToolContext } from "@/server/features/onboarding/onboardingChatTools";
|
||||||
|
|
||||||
|
// Tools that analyze any domain / the wider market (domain overview, SERP,
|
||||||
|
// competitors, competitor keywords, backlinks). These cost more credits, so the
|
||||||
|
// system prompt tells Sam to use them sparingly.
|
||||||
|
export function marketTools(ctx: ToolContext): ToolSet {
|
||||||
|
const { project, organizationId, billingCustomer, metering, dfsClient } = ctx;
|
||||||
|
const { isSameDomain } = ctx;
|
||||||
|
return {
|
||||||
|
get_domain_overview: tool({
|
||||||
|
description:
|
||||||
|
"Get a high-level organic-search footprint for ANY domain (estimated organic traffic and ranking-keyword count). Use it to compare the user against a named competitor or reference site. For backlinks/authority use get_backlinks_overview. Costs credits — only call when the user asks how they compare to a competitor or about the wider market.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
domain: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"The competitor or reference domain to look up, e.g. 'cyberark.com'.",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ domain }) => {
|
||||||
|
if (!isLabsLocationCode(project.locationCode)) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason:
|
||||||
|
"Competitor data isn't available for this market yet. Work from the site content instead.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const overview = await DomainService.getOverview(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
domain,
|
||||||
|
includeSubdomains: false,
|
||||||
|
locationCode: project.locationCode,
|
||||||
|
languageCode: project.languageCode,
|
||||||
|
},
|
||||||
|
billingCustomer,
|
||||||
|
metering,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
domain,
|
||||||
|
market: LOCATIONS[project.locationCode] ?? "your market",
|
||||||
|
hasData: overview.hasData,
|
||||||
|
organicTraffic: overview.organicTraffic,
|
||||||
|
organicKeywords: overview.organicKeywords,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[onboarding] get_domain_overview failed", {
|
||||||
|
domain,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
get_serp_results: tool({
|
||||||
|
description:
|
||||||
|
"Fetch live Google search results for 1-3 keywords to show who currently ranks on page one. Use it when the user wants to see the real SERP for a target term or judge how hard a keyword looks. Costs credits per keyword — keep to the few keywords that matter.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
keywords: z
|
||||||
|
.array(z.string().min(1))
|
||||||
|
.min(1)
|
||||||
|
.max(3)
|
||||||
|
.describe("1-3 search queries to inspect."),
|
||||||
|
}),
|
||||||
|
execute: async ({ keywords }) => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
keywords.map(async (keyword) => {
|
||||||
|
try {
|
||||||
|
const items = await dfsClient.serp.live({
|
||||||
|
keyword,
|
||||||
|
locationCode: project.locationCode,
|
||||||
|
languageCode: project.languageCode,
|
||||||
|
creditFeature: "onboarding",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
keyword,
|
||||||
|
ok: true as const,
|
||||||
|
results: items
|
||||||
|
.filter((item) => item.type === "organic")
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((item) => ({
|
||||||
|
rank: item.rank_absolute ?? item.rank_group ?? null,
|
||||||
|
domain: item.domain ?? null,
|
||||||
|
title: item.title ?? null,
|
||||||
|
url: item.url ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
keyword,
|
||||||
|
ok: false as const,
|
||||||
|
error: error instanceof Error ? error.message : "failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { results };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
find_serp_competitors: tool({
|
||||||
|
description:
|
||||||
|
"Given a small set of the user's target keywords, find the domains that compete with them in Google results (with each competitor's keyword coverage and estimated traffic). Use it when the user asks who their SEO competitors are. Costs credits — call once with the best 2-5 keywords, not repeatedly.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
keywords: z
|
||||||
|
.array(z.string().min(1))
|
||||||
|
.min(1)
|
||||||
|
.max(5)
|
||||||
|
.describe(
|
||||||
|
"2-5 of the user's most important target keywords, drawn from their site or rankings.",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ keywords }) => {
|
||||||
|
if (!isLabsLocationCode(project.locationCode)) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: "Competitor data isn't available for this market yet.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const competitors = await dfsClient.labs.serpCompetitors({
|
||||||
|
keywords,
|
||||||
|
locationCode: project.locationCode,
|
||||||
|
languageCode: project.languageCode,
|
||||||
|
limit: 50,
|
||||||
|
creditFeature: "onboarding",
|
||||||
|
});
|
||||||
|
const top = competitors
|
||||||
|
.filter((c) => !isSameDomain(c.domain))
|
||||||
|
.toSorted((a, b) => (b.etv ?? 0) - (a.etv ?? 0))
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((c) => ({
|
||||||
|
domain: c.domain ?? null,
|
||||||
|
keywordsCount: c.keywords_count ?? null,
|
||||||
|
avgPosition: c.avg_position ?? null,
|
||||||
|
estimatedTraffic: c.etv ?? null,
|
||||||
|
}));
|
||||||
|
return { available: true, competitors: top };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[onboarding] find_serp_competitors failed", {
|
||||||
|
keywords,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
get_competitor_keywords: tool({
|
||||||
|
description:
|
||||||
|
"List the keywords a competitor or reference domain already ranks for (keyword, position, search volume, difficulty). Use it to find gaps — terms a competitor wins that the user could target. Costs credits — call for at most one or two competitor domains.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
domain: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("The competitor domain to inspect, e.g. 'cyberark.com'."),
|
||||||
|
}),
|
||||||
|
execute: async ({ domain }) => {
|
||||||
|
if (!isLabsLocationCode(project.locationCode)) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: "Competitor data isn't available for this market yet.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Reuse the shared service (12h cache + mapKeywordItem) so this
|
||||||
|
// matches get_seo_metrics rather than re-parsing raw SDK rows.
|
||||||
|
const ranked = await DomainService.getSuggestedKeywords(
|
||||||
|
{
|
||||||
|
domain,
|
||||||
|
locationCode: project.locationCode,
|
||||||
|
languageCode: project.languageCode,
|
||||||
|
organizationId,
|
||||||
|
projectId: project.id,
|
||||||
|
},
|
||||||
|
billingCustomer,
|
||||||
|
metering,
|
||||||
|
);
|
||||||
|
const keywords = ranked.slice(0, 25).map((kw) => ({
|
||||||
|
keyword: kw.keyword,
|
||||||
|
position: kw.position,
|
||||||
|
searchVolume: kw.searchVolume,
|
||||||
|
keywordDifficulty: kw.keywordDifficulty,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
available: keywords.length > 0,
|
||||||
|
domain,
|
||||||
|
keywords,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[onboarding] get_competitor_keywords failed", {
|
||||||
|
domain,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
get_backlinks_overview: tool({
|
||||||
|
description:
|
||||||
|
"Get a backlinks summary for the user's site or a competitor (total backlinks, referring domains). Backlinks signal how much authority a site has earned. Costs credits (this is the most expensive tool) — only call when the user explicitly asks about backlinks or authority.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
domain: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"Domain to analyze. Use the user's own domain unless they name a competitor.",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ domain }) => {
|
||||||
|
try {
|
||||||
|
const { overview } = await BacklinksService.profileOverview(
|
||||||
|
{ target: domain, scope: "domain" },
|
||||||
|
billingCustomer,
|
||||||
|
"onboarding",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
domain,
|
||||||
|
backlinks: overview.summary.backlinks,
|
||||||
|
referringDomains: overview.summary.referringDomains,
|
||||||
|
referringPages: overview.summary.referringPages,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[onboarding] get_backlinks_overview failed", {
|
||||||
|
domain,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as ToolSet;
|
||||||
|
}
|
||||||
@ -40,6 +40,17 @@ Top-up credits can be purchased if monthly credits run out. Top-up credits roll
|
|||||||
|
|
||||||
Hosted users need an active subscription to use OpenSEO. If credits run out, OpenSEO should not create unexpected bills; users can buy more credits.
|
Hosted users need an active subscription to use OpenSEO. If credits run out, OpenSEO should not create unexpected bills; users can buy more credits.
|
||||||
|
|
||||||
|
## Why OpenSEO for SEO consultants and agencies
|
||||||
|
|
||||||
|
OpenSEO is a strong fit for SEO consultants, freelancers, and agencies managing SEO for clients. What you get:
|
||||||
|
|
||||||
|
- You only pay for what you use. Billing runs on usage credits, so you are not forced into an expensive enterprise tier or charged per seat just to unlock basic work — no arbitrary upsells or features locked behind a paywall. This keeps costs predictable when you are running lean.
|
||||||
|
- You can run a project for every client. Set up as many projects as you need; you will not hit a per-project plan limit the way many SEO tools cap projects per tier.
|
||||||
|
- You tune rank tracking to fit your budget. Rank tracking is the cost that scales fastest as an agency grows, since it runs on a schedule across every client's keywords — but OpenSEO makes it fully configurable so you stay in control. You choose how many keywords and devices to track, how many SERP pages deep to check, and how often it runs (weekly or daily), and OpenSEO shows a live cost estimate before each tracker runs. Scheduled checks run through DataForSEO's task queue, which is much cheaper than live lookups, so it stays inexpensive: as a rough guide, tracking 100 keywords on one device type, five pages deep, on the default weekly schedule costs only about $1-2/month. Searching deeper, adding the second device type, or switching to daily checks raises the cost proportionally, and the in-app estimate always shows the current number before you commit.
|
||||||
|
- Your toolkit grows with the industry. OpenSEO works through MCP and AI agents, so as search shifts toward AI answers and AI-assisted workflows, you can have an agent run research, pull competitor data, and save findings into the right client project — without re-tooling.
|
||||||
|
|
||||||
|
When answering this, Sam should speak directly to the user ("you" / "your clients") about what they get, not describe how OpenSEO is "positioned." Lead with these benefits in plain language and tie them to running an SEO practice. Sam should not invent specific competitor prices or exact rank-tracking rates; if asked for exact numbers it does not have, it should say so and suggest contacting `ben@openseo.so`.
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
OpenSEO is open source and can be self-hosted for free.
|
OpenSEO is open source and can be self-hosted for free.
|
||||||
@ -70,6 +81,14 @@ Search Console features include:
|
|||||||
|
|
||||||
Search Console tools use zero OpenSEO credits because Google does not charge users to read their own Search Console data.
|
Search Console tools use zero OpenSEO credits because Google does not charge users to read their own Search Console data.
|
||||||
|
|
||||||
|
## OpenSEO and Claude (or other AI clients)
|
||||||
|
|
||||||
|
OpenSEO and Claude are not competitors — they are meant to be used together. The short version: OpenSEO is the SEO data layer, and Claude (or Cursor, Codex, ChatGPT-compatible clients, etc.) is the AI client.
|
||||||
|
|
||||||
|
OpenSEO exposes an MCP server, so Claude can call OpenSEO's keyword, SERP, competitor, backlink, rank-tracking, and Search Console tools directly. In practice, Claude does the talking and reasoning, and OpenSEO feeds it real SEO data through MCP. Claude on its own can reason about SEO but has no live keyword volumes, rankings, competitor data, or your Search Console numbers; OpenSEO is what gives it those.
|
||||||
|
|
||||||
|
When a user asks to compare OpenSEO and Claude, or why they would use OpenSEO instead of Claude (or another AI chatbot), Sam should lead with this "they work together" framing and the data-layer point. Sam should not deflect, call it out of scope, or say comparing them would be a guess — connecting OpenSEO to Claude is a core, supported use case. Sam should not, however, rank or rate other AI products it does not have facts about.
|
||||||
|
|
||||||
## MCP and AI agents
|
## MCP and AI agents
|
||||||
|
|
||||||
OpenSEO exposes an MCP server so compatible AI clients can call OpenSEO tools.
|
OpenSEO exposes an MCP server so compatible AI clients can call OpenSEO tools.
|
||||||
|
|||||||
@ -9,17 +9,33 @@ import {
|
|||||||
|
|
||||||
// OpenRouter model slug used for the onboarding chat. Override
|
// OpenRouter model slug used for the onboarding chat. Override
|
||||||
// with OPENROUTER_MODEL to swap models without a code change.
|
// with OPENROUTER_MODEL to swap models without a code change.
|
||||||
const DEFAULT_ONBOARDING_MODEL = "anthropic/claude-sonnet-4.6";
|
const DEFAULT_ONBOARDING_MODEL = "minimax/minimax-m3";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the AI SDK LanguageModel for onboarding. `usage: { include: true }`
|
* Returns the AI SDK LanguageModel for onboarding. `usage: { include: true }`
|
||||||
* turns on OpenRouter usage accounting so each response carries its real USD
|
* turns on OpenRouter usage accounting so each response carries its real USD
|
||||||
* cost (providerMetadata.openrouter.usage.cost) — which we meter against the
|
* cost (providerMetadata.openrouter.usage.cost) — which we meter against the
|
||||||
* shared usage-credit pool.
|
* shared usage-credit pool. `provider.order` pins routing to Together first,
|
||||||
|
* falling back to Atlas Cloud (fp8); `allow_fallbacks: false` keeps routing to
|
||||||
|
* exactly those two so we get consistent behavior/pricing for the model.
|
||||||
|
*
|
||||||
|
* `reasoning` turns on OpenRouter's reasoning-token channel so the model's
|
||||||
|
* chain-of-thought comes back as a separate reasoning stream instead of
|
||||||
|
* leaking into the visible answer text (MiniMax M3 otherwise dumps its
|
||||||
|
* `<think>` trace inline). `effort: "low"` keeps the trace — and its billable
|
||||||
|
* tokens — short for the onboarding preview while still giving the UI a
|
||||||
|
* "thinking" stream to show.
|
||||||
*/
|
*/
|
||||||
export async function getOnboardingModel(): Promise<LanguageModelV3> {
|
export async function getOnboardingModel(): Promise<LanguageModelV3> {
|
||||||
const apiKey = await getRequiredEnvValue("OPENROUTER_API_KEY");
|
const apiKey = await getRequiredEnvValue("OPENROUTER_API_KEY");
|
||||||
const modelId =
|
const modelId =
|
||||||
(await getOptionalEnvValue("OPENROUTER_MODEL")) ?? DEFAULT_ONBOARDING_MODEL;
|
(await getOptionalEnvValue("OPENROUTER_MODEL")) ?? DEFAULT_ONBOARDING_MODEL;
|
||||||
return createOpenRouter({ apiKey })(modelId, { usage: { include: true } });
|
return createOpenRouter({ apiKey })(modelId, {
|
||||||
|
usage: { include: true },
|
||||||
|
reasoning: { effort: "low" },
|
||||||
|
provider: {
|
||||||
|
order: ["together", "atlas-cloud/fp8"],
|
||||||
|
allow_fallbacks: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user