diff --git a/src/client/components/chat/useStickToBottom.ts b/src/client/components/chat/useStickToBottom.ts new file mode 100644 index 0000000..c1c3a13 --- /dev/null +++ b/src/client/components/chat/useStickToBottom.ts @@ -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(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 }; +} diff --git a/src/client/features/onboarding/OnboardingChatConversation.tsx b/src/client/features/onboarding/OnboardingChatConversation.tsx index 598dbf1..f7b52a6 100644 --- a/src/client/features/onboarding/OnboardingChatConversation.tsx +++ b/src/client/features/onboarding/OnboardingChatConversation.tsx @@ -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(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({ />
-
+
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], diff --git a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx index 4599a0e..a58d062 100644 --- a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx +++ b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx @@ -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) => ( ))} +
+ Defaults to the country's language. Any language can be tracked in + any country — pick the one your customers search in. +
diff --git a/src/client/features/sam/SamConversation.tsx b/src/client/features/sam/SamConversation.tsx index ed27bf4..4a80822 100644 --- a/src/client/features/sam/SamConversation.tsx +++ b/src/client/features/sam/SamConversation.tsx @@ -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(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) ) : null} -
+
{messages.length === 0 ? (
diff --git a/src/server/features/rank-tracking/services/RankTrackingService.ts b/src/server/features/rank-tracking/services/RankTrackingService.ts index 293bd3a..7e34fbe 100644 --- a/src/server/features/rank-tracking/services/RankTrackingService.ts +++ b/src/server/features/rank-tracking/services/RankTrackingService.ts @@ -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, diff --git a/src/server/features/sam/SamChatAgent.ts b/src/server/features/sam/SamChatAgent.ts index fce0808..950a91b 100644 --- a/src/server/features/sam/SamChatAgent.ts +++ b/src/server/features/sam/SamChatAgent.ts @@ -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 { @@ -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 diff --git a/src/server/lib/chatAgent.ts b/src/server/lib/chatAgent.ts index 4ab0282..cd588c8 100644 --- a/src/server/lib/chatAgent.ts +++ b/src/server/lib/chatAgent.ts @@ -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>; + +// 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 }; + }, + }; +} diff --git a/src/shared/keyword-locations.test.ts b/src/shared/keyword-locations.test.ts index 0670ae1..1689db6 100644 --- a/src/shared/keyword-locations.test.ts +++ b/src/shared/keyword-locations.test.ts @@ -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"); + }); +}); diff --git a/src/shared/keyword-locations.ts b/src/shared/keyword-locations.ts index 3a67472..7d9dfb0 100644 --- a/src/shared/keyword-locations.ts +++ b/src/shared/keyword-locations.ts @@ -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 = Object.fromEntries( ); const SUPPORTED_LANGUAGE_CODES = new Set( - 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 = { 2012: ["ar", "fr"], // Algeria @@ -798,17 +800,36 @@ const MULTI_LANGUAGE_LOCATIONS: Record = { }; /** - * 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 { diff --git a/src/types/schemas/rank-tracking.ts b/src/types/schemas/rank-tracking.ts index dfc9ff3..69bf73c 100644 --- a/src/types/schemas/rank-tracking.ts +++ b/src/types/schemas/rank-tracking.ts @@ -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(),