fix(sam): out-of-credits CoT leak, streaming scroll lock, rank-tracking language gap (#470)
* fix(sam): stream canned refusals without a provider call Out-of-credits and session-gone refusals ran a real LLM turn with a 200-token cap; MiniMax M3 could spend the whole budget on reasoning tokens, leaving the user a raw truncated chain-of-thought (which also named the backing model) and no reply. Refusal turns now swap in a static LanguageModelV3 that streams the refusal text through Think's normal pipeline — rendered and persisted like any assistant message, with no provider request at all. Fixes every-app/open-seo#161 * fix(chat): stop pinning the transcript to the bottom while the user scrolls up Both chat surfaces (SAM and onboarding) forced scrollTop to the bottom on every streamed chunk, so scrolling up mid-reply was undone within milliseconds. A shared stick-to-bottom hook now tracks pinned-ness from real scroll events: scrolling away releases the pin, returning to the bottom (or sending a message) re-arms it. Fixes every-app/open-seo#160 * fix(rank-tracking): allow any SERP language for any country The Add Domain modal restricted the language picker to the Labs per-country subset and disabled it when only one option existed, so e.g. tracking English searches in Czechia was impossible — even though rank tracking runs against the SERP API, which serves every supported language in every country. The picker now offers the full SERP language list; create/update schemas validate codes against the master list so unknown codes still fail before DataForSEO charges for them; and keyword-metrics refreshes resolve a Labs-served language so an unserved pair never reaches a charged Labs call. Fixes every-app/open-seo#183 * fix(chat): surface silent turn failures in Workers logs A provider stream dying mid-turn (chat:request:failed) and a DO restart whose recovery gives up (chat:recovery:exhausted) leave the user a replayed "Something went wrong" banner and a half-streamed message, but never reach the onChatError hook — their only signal is the agents:chat diagnostics channel, which was unsubscribed, so the chat agents' most common failure modes produced zero log lines. A module-level subscription now logs both for every chat DO. SamChatAgent.onChatError also returns the error now: Think uses the return value as the stored chat-terminal body that reconnecting clients replay, and returning void stored the literal string "undefined".
This commit is contained in:
parent
17e7515e82
commit
13ada5b441
44
src/client/components/chat/useStickToBottom.ts
Normal file
44
src/client/components/chat/useStickToBottom.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
|
||||
// Within this many pixels of the bottom still counts as "following along", so
|
||||
// sub-pixel rounding and a half-rendered line don't unpin the view.
|
||||
const BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
/**
|
||||
* Keeps a chat scroll container pinned to the bottom while the user follows
|
||||
* along, and stops pinning as soon as they scroll up — until they scroll back
|
||||
* to the bottom, or `pinToBottom` is called because they sent a message.
|
||||
*
|
||||
* Pinned-ness is tracked from real scroll events rather than measured when
|
||||
* `messages` changes: by the time the effect runs the new chunk is already in
|
||||
* the DOM, so the distance to the bottom would look large and unpin mid-stream.
|
||||
*/
|
||||
export function useStickToBottom(messages: readonly unknown[], status: string) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedRef = useRef(true);
|
||||
|
||||
// useLayoutEffect (not useEffect) so the jump happens before paint and the
|
||||
// new chunk is never briefly visible below the fold.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, status]);
|
||||
|
||||
// Fires for our own pinning too, which simply recomputes to `true` — the jump
|
||||
// is instant, so there is no in-flight smooth scroll to misread.
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
pinnedRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX;
|
||||
};
|
||||
|
||||
// Sending re-pins: the user expects to follow their own message and the reply.
|
||||
const pinToBottom = () => {
|
||||
pinnedRef.current = true;
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
};
|
||||
|
||||
return { scrollRef, onScroll, pinToBottom };
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
import { useAgent } from "agents/react";
|
||||
import { useAgentChat } from "@cloudflare/ai-chat/react";
|
||||
import { useCustomer } from "autumn-js/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChatMessage,
|
||||
messageHasVisibleContent,
|
||||
type ResolveToolLabel,
|
||||
} from "@/client/components/chat/ChatMessage";
|
||||
import { useStickToBottom } from "@/client/components/chat/useStickToBottom";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
|
||||
@ -106,7 +107,14 @@ export function OnboardingChatConversation({
|
||||
const showRemainingHint = remaining > 0 && remaining <= 3;
|
||||
|
||||
const isBusy = status === "submitted" || status === "streaming";
|
||||
const sendText = (text: string) => void sendMessage({ text });
|
||||
const { scrollRef, onScroll, pinToBottom } = useStickToBottom(
|
||||
messages,
|
||||
status,
|
||||
);
|
||||
const sendText = (text: string) => {
|
||||
pinToBottom();
|
||||
void sendMessage({ text });
|
||||
};
|
||||
async function startCheckout() {
|
||||
setCheckoutError(null);
|
||||
setIsStartingCheckout(true);
|
||||
@ -130,14 +138,6 @@ export function OnboardingChatConversation({
|
||||
}
|
||||
}
|
||||
|
||||
// Pin to the bottom while the user is following along; the strategy doc plus
|
||||
// a streaming reply quickly grows past the viewport.
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, status]);
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const suggestionPool = [
|
||||
...(strategyRequested ? [] : [STRATEGY_SUGGESTION]),
|
||||
@ -173,7 +173,11 @@ export function OnboardingChatConversation({
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-6">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="flex-1 overflow-y-auto px-5 py-6"
|
||||
>
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<WelcomeMessage
|
||||
domain={domain}
|
||||
|
||||
@ -130,7 +130,6 @@ type Props = {
|
||||
projectId: string;
|
||||
domain: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
onDone: (configId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
@ -160,6 +159,9 @@ export function KeywordSuggestionStep({
|
||||
|
||||
// Ranked-keyword suggestions are Labs-backed; countries served from Google
|
||||
// Ads keyword data (e.g. Iceland) have no ranking data to suggest from.
|
||||
// The tracker's language is deliberately not sent — rank tracking can pair
|
||||
// any SERP language with any country, and the server resolves a Labs-served
|
||||
// language for this country (resolveLabsMarket in serverFunctions/domain.ts).
|
||||
const labsSupported = isLabsLocationCode(locationCode);
|
||||
const suggestionsQuery = useQuery({
|
||||
queryKey: ["domainKeywordSuggestions", projectId, domain, locationCode],
|
||||
|
||||
@ -9,11 +9,11 @@ import {
|
||||
pagesToDepth,
|
||||
estimateRankCheckCredits,
|
||||
} from "@/shared/rank-tracking";
|
||||
import { getLanguageCode } from "@/client/features/keywords/locations";
|
||||
import {
|
||||
getLanguageCode,
|
||||
getLanguageOptions,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { getIsoCountryCode } from "@/shared/keyword-locations";
|
||||
SERP_LANGUAGE_OPTIONS,
|
||||
getIsoCountryCode,
|
||||
} from "@/shared/keyword-locations";
|
||||
import { LocationSelect } from "@/client/components/LocationSelect";
|
||||
import type { ProjectMarket } from "@/client/features/projects/types";
|
||||
import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
|
||||
@ -87,10 +87,6 @@ function RankTrackingConfigModalContent({
|
||||
const [languageCode, setLanguageCode] = useState(
|
||||
existingConfig?.languageCode ?? initialMarket.languageCode,
|
||||
);
|
||||
const languageOptions = useMemo(
|
||||
() => getLanguageOptions(locationCode),
|
||||
[locationCode],
|
||||
);
|
||||
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40);
|
||||
const [schedule, setSchedule] = useState<
|
||||
RankTrackingConfig["scheduleInterval"]
|
||||
@ -176,7 +172,6 @@ function RankTrackingConfigModalContent({
|
||||
projectId={projectId}
|
||||
domain={domain}
|
||||
locationCode={locationCode}
|
||||
languageCode={languageCode}
|
||||
onDone={(id) => onSaved(id)}
|
||||
onClose={closeKeywordStep}
|
||||
/>
|
||||
@ -245,14 +240,17 @@ function RankTrackingConfigModalContent({
|
||||
className="select select-bordered w-full"
|
||||
value={languageCode}
|
||||
onChange={(e) => setLanguageCode(e.target.value)}
|
||||
disabled={languageOptions.length <= 1}
|
||||
>
|
||||
{languageOptions.map((language) => (
|
||||
{SERP_LANGUAGE_OPTIONS.map((language) => (
|
||||
<option key={language.code} value={language.code}>
|
||||
{language.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-1.5 text-xs text-base-content/50">
|
||||
Defaults to the country's language. Any language can be tracked in
|
||||
any country — pick the one your customers search in.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
humanizeToolLabel,
|
||||
messageHasVisibleContent,
|
||||
} from "@/client/components/chat/ChatMessage";
|
||||
import { useStickToBottom } from "@/client/components/chat/useStickToBottom";
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What keywords should I focus on next?",
|
||||
@ -33,7 +34,14 @@ export function SamConversation({
|
||||
useAgentChat({ agent });
|
||||
|
||||
const isBusy = status === "submitted" || status === "streaming";
|
||||
const sendText = (text: string) => void sendMessage({ text });
|
||||
const { scrollRef, onScroll, pinToBottom } = useStickToBottom(
|
||||
messages,
|
||||
status,
|
||||
);
|
||||
const sendText = (text: string) => {
|
||||
pinToBottom();
|
||||
void sendMessage({ text });
|
||||
};
|
||||
|
||||
// Rewind the server-side conversation to before `messageId`: the DO aborts
|
||||
// any in-flight turn, then deletes the message and everything after it. Sync
|
||||
@ -56,7 +64,7 @@ export function SamConversation({
|
||||
|
||||
const undoFrom = (messageId: string) => void rewindTo(messageId);
|
||||
const editAndResend = async (messageId: string, newText: string) => {
|
||||
if (await rewindTo(messageId)) void sendMessage({ text: newText });
|
||||
if (await rewindTo(messageId)) sendText(newText);
|
||||
};
|
||||
|
||||
// The DO names the session from its first message during the turn, so refresh
|
||||
@ -73,13 +81,6 @@ export function SamConversation({
|
||||
}
|
||||
}, [isBusy, projectId]);
|
||||
|
||||
// Pin to the bottom while the user follows along.
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, status]);
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const showTyping =
|
||||
isBusy &&
|
||||
@ -101,7 +102,11 @@ export function SamConversation({
|
||||
Clear history (dev)
|
||||
</button>
|
||||
) : null}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-6">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="flex-1 overflow-y-auto px-5 py-6"
|
||||
>
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-2 text-sm text-base-content/80">
|
||||
|
||||
@ -25,7 +25,10 @@ import {
|
||||
MAX_CONFIGS_PER_PROJECT,
|
||||
rankCheckCostApprovalError,
|
||||
} from "@/shared/rank-tracking";
|
||||
import { resolveMarket } from "@/shared/keyword-locations";
|
||||
import {
|
||||
resolveKeywordDataLanguage,
|
||||
resolveMarket,
|
||||
} from "@/shared/keyword-locations";
|
||||
import { getLatestResults } from "./rankTrackingResults";
|
||||
import { toSqliteTimestamp } from "@/server/features/rank-tracking/rankTrackingTimestamps";
|
||||
import { RankTrackingKeywordService } from "./RankTrackingKeywordService";
|
||||
@ -264,7 +267,12 @@ async function refreshKeywordMetrics(
|
||||
const metrics = await fetchKeywordMetricsForList(client, {
|
||||
keywords: keywords.map((kw) => kw.keyword),
|
||||
locationCode: config.locationCode,
|
||||
languageCode: config.languageCode,
|
||||
// Trackers can pair any SERP language with any country; the keyword-data
|
||||
// APIs only serve the country's own languages.
|
||||
languageCode: resolveKeywordDataLanguage(
|
||||
config.locationCode,
|
||||
config.languageCode,
|
||||
),
|
||||
// Local configs get volume/CPC scoped to the tracked city; national
|
||||
// numbers can overstate local demand by orders of magnitude.
|
||||
locationName: config.locationName ?? undefined,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Think } from "@cloudflare/think";
|
||||
import type {
|
||||
ChatErrorContext,
|
||||
ChatResponseResult,
|
||||
Session,
|
||||
StepContext,
|
||||
@ -12,7 +13,10 @@ import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, withPgClient } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { openRouterCostUsd } from "@/server/lib/chatAgent";
|
||||
import {
|
||||
openRouterCostUsd,
|
||||
staticAssistantModel,
|
||||
} from "@/server/lib/chatAgent";
|
||||
import { SamSessionRepository } from "@/server/features/sam/SamSessionRepository";
|
||||
import { SamProjectMemoryRepository } from "@/server/features/sam/SamProjectMemoryRepository";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
@ -202,21 +206,14 @@ export class SamChatAgent extends Think {
|
||||
};
|
||||
}
|
||||
|
||||
// Gates reshape the turn: no tools, a tiny budget, a system prompt that
|
||||
// pins the exact reply, and no history — so the (unmetered) LLM call a
|
||||
// refusal still makes costs a constant few hundred tokens even when users
|
||||
// script them. Think's no-model path (deliverNotice + cancelAllChats)
|
||||
// would make refusals free but hasn't been validated against the chat UI's
|
||||
// rendering of an aborted turn; swap it in only after checking that.
|
||||
// Gates swap the model for one turn: the canned model streams the refusal
|
||||
// back through Think's normal pipeline (rendered and persisted like any
|
||||
// assistant message) without calling a provider, so a refusal is free even
|
||||
// when users script them. The old version made a real 200-token call, which
|
||||
// MiniMax M3 could spend entirely on reasoning tokens — leaving the user a
|
||||
// truncated chain-of-thought and no reply (issue #161).
|
||||
private refusalTurn(text: string): TurnConfig {
|
||||
return {
|
||||
system: `Reply with exactly the following message and nothing else: ${text}`,
|
||||
messages: [{ role: "user", content: "Acknowledge." }],
|
||||
activeTools: [],
|
||||
maxSteps: 1,
|
||||
maxOutputTokens: 200,
|
||||
maxRetries: 0,
|
||||
};
|
||||
return { model: staticAssistantModel(text) };
|
||||
}
|
||||
|
||||
async beforeTurn(_ctx: TurnContext): Promise<TurnConfig> {
|
||||
@ -329,8 +326,11 @@ export class SamChatAgent extends Think {
|
||||
}
|
||||
}
|
||||
|
||||
onChatError(error: unknown): void {
|
||||
console.error("[sam] chat turn error", error);
|
||||
// The return value becomes the stored chat-terminal body that reconnecting
|
||||
// clients replay — returning nothing would make it the string "undefined".
|
||||
onChatError(error: unknown, ctx?: ChatErrorContext): unknown {
|
||||
console.error("[sam] chat turn error", ctx?.stage, error);
|
||||
return error;
|
||||
}
|
||||
|
||||
// POST .../rewind {messageId}: delete that message and everything after it on
|
||||
|
||||
@ -1,6 +1,29 @@
|
||||
import type { LanguageModelV3 } from "@openrouter/ai-sdk-provider";
|
||||
import { subscribe } from "agents/observability";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
// The chat agents' most common failure modes — a provider stream dying
|
||||
// mid-turn ("chat:request:failed", stage "stream") and a DO restart whose
|
||||
// recovery gives up ("chat:recovery:exhausted") — never reach an onChatError
|
||||
// hook; their only signal is the agents:chat diagnostics channel, which is
|
||||
// silent without a subscriber. This module-level subscription puts them in
|
||||
// the Workers logs for every chat DO in the isolate (SAM + onboarding). The
|
||||
// user-visible residue of these is the replayed "Something went wrong"
|
||||
// banner plus a partially-streamed assistant message.
|
||||
subscribe("chat", (event) => {
|
||||
if (
|
||||
event.type === "chat:request:failed" ||
|
||||
event.type === "chat:recovery:exhausted"
|
||||
) {
|
||||
console.error(
|
||||
`[chat] ${event.type}`,
|
||||
{ agent: event.agent, name: event.name },
|
||||
event.payload,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// OpenRouter (with usage accounting on) reports the real USD cost of each
|
||||
// response under providerMetadata.openrouter.usage.cost. Shared by the chat
|
||||
// agents (onboarding + SAM) that meter LLM spend against the credit pool.
|
||||
@ -27,3 +50,52 @@ export function staticAssistantResponse(text: string): Response {
|
||||
});
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
|
||||
// The provider package re-exports only LanguageModelV3 itself, so the stream
|
||||
// shape is derived from the interface.
|
||||
type StreamResult = Awaited<ReturnType<LanguageModelV3["doStream"]>>;
|
||||
|
||||
// Nothing is generated, so every counter is zero and there is no provider
|
||||
// finish reason to report.
|
||||
const NO_USAGE = {
|
||||
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
outputTokens: { total: 0, text: 0, reasoning: 0 },
|
||||
};
|
||||
const FINISH_STOP = { unified: "stop", raw: undefined } as const;
|
||||
|
||||
// A model that ignores its prompt and streams `text` back verbatim. Lets a
|
||||
// Think agent answer a gated turn through the normal turn pipeline — streamed,
|
||||
// persisted and rendered like any other assistant message — without ever
|
||||
// calling a provider: no request, no tokens, and no reasoning channel that
|
||||
// could leak a chain-of-thought instead of the reply.
|
||||
export function staticAssistantModel(text: string): LanguageModelV3 {
|
||||
return {
|
||||
specificationVersion: "v3",
|
||||
provider: "openseo",
|
||||
modelId: "static-assistant",
|
||||
supportedUrls: {},
|
||||
doGenerate: async () => ({
|
||||
content: [{ type: "text", text }],
|
||||
finishReason: FINISH_STOP,
|
||||
usage: NO_USAGE,
|
||||
warnings: [],
|
||||
}),
|
||||
doStream: async () => {
|
||||
const stream: StreamResult["stream"] = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({ type: "stream-start", warnings: [] });
|
||||
controller.enqueue({ type: "text-start", id: "0" });
|
||||
controller.enqueue({ type: "text-delta", id: "0", delta: text });
|
||||
controller.enqueue({ type: "text-end", id: "0" });
|
||||
controller.enqueue({
|
||||
type: "finish",
|
||||
finishReason: FINISH_STOP,
|
||||
usage: NO_USAGE,
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return { stream };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
isLabsLocationCode,
|
||||
isSupportedLanguageCode,
|
||||
isSupportedLocationCode,
|
||||
resolveKeywordDataLanguage,
|
||||
resolveLabsMarket,
|
||||
resolveMarket,
|
||||
} from "./keyword-locations";
|
||||
@ -169,3 +170,16 @@ describe("resolveLabsMarket", () => {
|
||||
).toMatchObject({ locationCode: 2352 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeywordDataLanguage", () => {
|
||||
it("keeps a language the country's keyword data serves", () => {
|
||||
expect(resolveKeywordDataLanguage(2840, "es")).toBe("es");
|
||||
});
|
||||
|
||||
it("falls back to the country default for a SERP-only pair", () => {
|
||||
// Rank tracking can track English in Czechia; Labs would charge and fail.
|
||||
expect(resolveKeywordDataLanguage(2203, "en")).toBe("cs");
|
||||
// Google-Ads countries keep their single default too.
|
||||
expect(resolveKeywordDataLanguage(2352, "en")).toBe("is");
|
||||
});
|
||||
});
|
||||
|
||||
@ -535,10 +535,11 @@ export const LOCATION_OPTIONS: readonly LocationOption[] = [
|
||||
* dropped (Norway uses `nb`, which both SERP and Labs accept). Every country
|
||||
* default in LOCATION_OPTIONS must appear here so the picker can show it.
|
||||
*
|
||||
* This is the master list; the picker shows a per-country subset via
|
||||
* This is the master list. Rank tracking (SERP) offers all of it for any
|
||||
* country; the Labs-backed project picker shows a per-country subset via
|
||||
* getLanguageOptions() below.
|
||||
*/
|
||||
const LANGUAGE_OPTIONS = [
|
||||
export const SERP_LANGUAGE_OPTIONS = [
|
||||
{ code: "af", label: "Afrikaans" },
|
||||
{ code: "ak", label: "Akan" },
|
||||
{ code: "sq", label: "Albanian" },
|
||||
@ -690,7 +691,7 @@ const LOCATION_LANGUAGE: Record<number, string> = Object.fromEntries(
|
||||
);
|
||||
|
||||
const SUPPORTED_LANGUAGE_CODES = new Set<string>(
|
||||
LANGUAGE_OPTIONS.map((language) => language.code),
|
||||
SERP_LANGUAGE_OPTIONS.map((language) => language.code),
|
||||
);
|
||||
|
||||
export function getLanguageCode(locationCode: number): string {
|
||||
@ -758,10 +759,11 @@ export function resolveLabsMarket(
|
||||
}
|
||||
|
||||
/**
|
||||
* Language codes DataForSEO accepts — the master LANGUAGE_OPTIONS list. Callers
|
||||
* (e.g. MCP tools) can pass an arbitrary `language_code`; an unsupported one is
|
||||
* otherwise rejected by DataForSEO as an opaque *charged* "Invalid Field:
|
||||
* 'language_code'." failure, so we validate against this set first (cost 0).
|
||||
* Language codes DataForSEO accepts — the master SERP_LANGUAGE_OPTIONS list.
|
||||
* Callers (e.g. MCP tools) can pass an arbitrary `language_code`; an
|
||||
* unsupported one is otherwise rejected by DataForSEO as an opaque *charged*
|
||||
* "Invalid Field: 'language_code'." failure, so we validate against this set
|
||||
* first (cost 0).
|
||||
*/
|
||||
export function isSupportedLanguageCode(languageCode: string): boolean {
|
||||
return SUPPORTED_LANGUAGE_CODES.has(languageCode);
|
||||
@ -772,7 +774,7 @@ export function isSupportedLanguageCode(languageCode: string): boolean {
|
||||
* locations_and_languages endpoint (each country's default is included).
|
||||
* Every other country offers just its single default (see getLanguageOptions);
|
||||
* googleAdsOnly countries have no per-country language data, so they fall back
|
||||
* to the default too. Keep each list's codes present in LANGUAGE_OPTIONS.
|
||||
* to the default too. Keep each list's codes present in SERP_LANGUAGE_OPTIONS.
|
||||
*/
|
||||
const MULTI_LANGUAGE_LOCATIONS: Record<number, readonly string[]> = {
|
||||
2012: ["ar", "fr"], // Algeria
|
||||
@ -798,17 +800,36 @@ const MULTI_LANGUAGE_LOCATIONS: Record<number, readonly string[]> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Languages to offer for a location. Restricts the global LANGUAGE_OPTIONS
|
||||
* Languages to offer for a location. Restricts the global SERP_LANGUAGE_OPTIONS
|
||||
* list to the languages DataForSEO supports for that country, so a picker
|
||||
* isn't a wall of irrelevant options.
|
||||
*/
|
||||
export function getLanguageOptions(
|
||||
locationCode: number,
|
||||
): readonly (typeof LANGUAGE_OPTIONS)[number][] {
|
||||
): readonly (typeof SERP_LANGUAGE_OPTIONS)[number][] {
|
||||
const codes = new Set(
|
||||
MULTI_LANGUAGE_LOCATIONS[locationCode] ?? [getLanguageCode(locationCode)],
|
||||
);
|
||||
return LANGUAGE_OPTIONS.filter((language) => codes.has(language.code));
|
||||
return SERP_LANGUAGE_OPTIONS.filter((language) => codes.has(language.code));
|
||||
}
|
||||
|
||||
/**
|
||||
* The language to send to the keyword-data APIs (Labs / Google Ads) for a
|
||||
* market whose language was chosen for the SERP API. SERP serves any language
|
||||
* in any country — rank tracking relies on that — but the keyword-data APIs
|
||||
* only serve a country's own languages and reject anything else as an opaque
|
||||
* *charged* "Invalid Field: 'language_code'." task failure. Falls back to the
|
||||
* country's default language.
|
||||
*/
|
||||
export function resolveKeywordDataLanguage(
|
||||
locationCode: number,
|
||||
languageCode: string,
|
||||
): string {
|
||||
return getLanguageOptions(locationCode).some(
|
||||
(option) => option.code === languageCode,
|
||||
)
|
||||
? languageCode
|
||||
: getLanguageCode(locationCode);
|
||||
}
|
||||
|
||||
export function isSupportedLocationCode(locationCode: number): boolean {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { rankTrackingConfigs } from "@/db/schema";
|
||||
import { isSupportedLanguageCode } from "@/shared/keyword-locations";
|
||||
import { MAX_TRACKED_KEYWORD_LENGTH } from "@/shared/rank-tracking";
|
||||
import { domainField } from "@/types/schemas/domain";
|
||||
|
||||
@ -48,6 +49,14 @@ export interface RankTrackingRow {
|
||||
|
||||
const devicesEnum = z.enum(rankTrackingConfigs.devices.enumValues);
|
||||
const scheduleEnum = z.enum(rankTrackingConfigs.scheduleInterval.enumValues);
|
||||
// Rank tracking runs against the SERP API, which serves any language in any
|
||||
// country — but an unknown code is a *charged* DataForSEO failure, so reject
|
||||
// it here at cost 0.
|
||||
const languageCodeField = z
|
||||
.string()
|
||||
.max(10)
|
||||
.refine(isSupportedLanguageCode, "Unsupported language code");
|
||||
|
||||
export const getConfigsSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
});
|
||||
@ -56,7 +65,7 @@ export const createConfigSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
domain: domainField,
|
||||
locationCode: z.number().int().positive().optional(),
|
||||
languageCode: z.string().max(10).optional(),
|
||||
languageCode: languageCodeField.optional(),
|
||||
locationName: z.string().min(1).max(200).optional(),
|
||||
devices: devicesEnum.optional(),
|
||||
serpDepth: z.number().int().min(10).max(100).multipleOf(10),
|
||||
@ -68,7 +77,7 @@ export const updateConfigSchema = z.object({
|
||||
configId: z.string().uuid(),
|
||||
domain: domainField.optional(),
|
||||
locationCode: z.number().int().positive().optional(),
|
||||
languageCode: z.string().max(10).optional(),
|
||||
languageCode: languageCodeField.optional(),
|
||||
locationName: z.string().min(1).max(200).nullable().optional(),
|
||||
devices: devicesEnum.optional(),
|
||||
serpDepth: z.number().int().min(10).max(100).multipleOf(10).optional(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user