diff --git a/docs/README.md b/docs/README.md index 332d32d37..470bb0057 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,35 @@ a shared, cross-instance session-and-IP rate limiter, Cloud organization budgets an approved conversation retention/deletion process are in place. Same-origin validation is an additional browser safeguard, not a substitute for those cost controls. +### Demo interaction analytics + +The four hosted demos (`/compare`, `/demo/github`, `/chat`, and `/demos`) emit +`demo_agent_interaction` PostHog events for accepted user interactions. Most turns emit one event; +`/compare` emits one per visible panel. The instrumentation supplies only these custom dimensions +(PostHog may also attach its standard page context): + +- `demo`: the stable demo identifier +- `variant`: the visible comparison mode or chat mode when applicable +- `model`: the canonical provider/model ID requested for the interaction +- `interaction_source`: `composer`, `starter`, or `rendered_action` + +`/compare` emits two events for a shared prompt, one per available mode in the selected visible +pair. Its hidden third controller still receives the prompt in the background, but that work is +not counted as a visible user interaction. A generated `continue_conversation` action emits one +event for the panel it targets. Prefill-only chips, blocked submissions, restored messages, and +local generated-UI state changes do not emit events. + +Demo analytics never add prompt or message content, generated output, action payloads, form state, +GitHub usernames, action or tool URLs, thread or Cloud user IDs, tool arguments, or raw errors. The +docs site uses the shared, fail-open PostHog client in `lib/analytics.ts`; published OpenUI packages +do not initialize or send analytics. + +OpenUI visitors remain anonymous. Outbound `thesys.dev` links carry PostHog's current anonymous +distinct ID and session ID as handoff query parameters, not event properties. The receiving console +validates and removes those parameters before analytics initialization, bootstraps the distinct ID +as anonymous, and later merges the demo history through its canonical `posthog.identify(user.id)` +call. Both applications must use the same PostHog project, and the console resets PostHog on logout. + ## Project structure ``` diff --git a/docs/app/api/chat/route.ts b/docs/app/api/chat/route.ts index e49212f6e..2528e9ef7 100644 --- a/docs/app/api/chat/route.ts +++ b/docs/app/api/chat/route.ts @@ -1,3 +1,4 @@ +import { DEFAULT_MODEL } from "@/lib/openui-cloud/models"; import { readFileSync } from "fs"; import { NextRequest } from "next/server"; import OpenAI from "openai"; @@ -293,7 +294,7 @@ export async function POST(req: NextRequest) { apiKey, baseURL: "https://openrouter.ai/api/v1", }); - const MODEL = "openai/gpt-5.4"; + const MODEL = DEFAULT_MODEL; const cleanMessages = (messages as any[]) .filter( diff --git a/docs/app/chat/_components/agent-surfaces/cloud-agent-surface.tsx b/docs/app/chat/_components/agent-surfaces/cloud-agent-surface.tsx index 16125deb7..a6b341dc4 100644 --- a/docs/app/chat/_components/agent-surfaces/cloud-agent-surface.tsx +++ b/docs/app/chat/_components/agent-surfaces/cloud-agent-surface.tsx @@ -1,5 +1,9 @@ "use client"; +import { + captureDemoAgentInteraction, + getDemoInteractionSourceFromMessages, +} from "@/lib/demo-analytics"; import { createCloudChatLLM } from "@/lib/openui-cloud/chat-llm"; import { DEFAULT_MODEL } from "@/lib/openui-cloud/models"; import { CLOUD_USER_ID_HEADER, getOrCreateCloudUserId } from "@/lib/openui-cloud/user-id"; @@ -34,11 +38,27 @@ const CLOUD_STARTERS = [ prompt: "Write a brief market-analysis report on the EV sector.", }, ]; +const CLOUD_STARTER_PROMPTS = CLOUD_STARTERS.map((starter) => starter.prompt); export function CloudAgentSurface() { const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); const [userId] = useState(getOrCreateCloudUserId); - const [llm] = useState(createCloudChatLLM); + const [llm] = useState(() => + createCloudChatLLM(({ model, messages }) => { + const interactionSource = getDemoInteractionSourceFromMessages( + messages, + CLOUD_STARTER_PROMPTS, + ); + if (!interactionSource) return; + + captureDemoAgentInteraction({ + demo: "openui_chat", + variant: "cloud", + model, + interaction_source: interactionSource, + }); + }), + ); const cloudFetch = useMemo(() => { return async (input, init) => { if (typeof input !== "string" || input !== "/api/openui-cloud/frontend-token") { diff --git a/docs/app/chat/_components/agent-surfaces/oss-agent-surface.tsx b/docs/app/chat/_components/agent-surfaces/oss-agent-surface.tsx index 0ad98166d..1b7163af3 100644 --- a/docs/app/chat/_components/agent-surfaces/oss-agent-surface.tsx +++ b/docs/app/chat/_components/agent-surfaces/oss-agent-surface.tsx @@ -1,6 +1,11 @@ "use client"; +import { + captureDemoAgentInteraction, + getDemoInteractionSourceFromMessages, +} from "@/lib/demo-analytics"; import { isDemoCreditsErrorPayload } from "@/lib/demo-credits"; +import { DEFAULT_MODEL } from "@/lib/openui-cloud/models"; import { AgentInterface, openAIAdapter, @@ -36,11 +41,25 @@ const OSS_STARTERS = [ "Show me a carousel of 3 popular travel destinations with images, descriptions, and best time to visit.", }, ]; +const OSS_STARTER_PROMPTS = OSS_STARTERS.map((starter) => starter.prompt); export function OssAgentSurface({ onCreditsExhausted }: OssAgentSurfaceProps) { const llm = useMemo( () => ({ send: async ({ messages, signal }) => { + const interactionSource = getDemoInteractionSourceFromMessages( + messages, + OSS_STARTER_PROMPTS, + ); + if (interactionSource) { + captureDemoAgentInteraction({ + demo: "openui_chat", + variant: "oss", + model: DEFAULT_MODEL, + interaction_source: interactionSource, + }); + } + const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/docs/app/compare/_components/agent-surfaces/cloud-agent-surface.tsx b/docs/app/compare/_components/agent-surfaces/cloud-agent-surface.tsx index fd65d8217..be3c95456 100644 --- a/docs/app/compare/_components/agent-surfaces/cloud-agent-surface.tsx +++ b/docs/app/compare/_components/agent-surfaces/cloud-agent-surface.tsx @@ -1,5 +1,9 @@ "use client"; +import { + captureDemoAgentInteraction, + getDemoInteractionSourceFromMessages, +} from "@/lib/demo-analytics"; import { createCloudChatLLM } from "@/lib/openui-cloud/chat-llm"; import { CLOUD_USER_ID_HEADER, getOrCreateCloudUserId } from "@/lib/openui-cloud/user-id"; import { AgentInterface } from "@openuidev/react-ui"; @@ -15,7 +19,18 @@ interface CloudAgentSurfaceProps { export function CloudAgentSurface({ registry }: CloudAgentSurfaceProps) { const [userId] = useState(getOrCreateCloudUserId); - const [llm] = useState(createCloudChatLLM); + const [llm] = useState(() => + createCloudChatLLM(({ model, messages }) => { + if (getDemoInteractionSourceFromMessages(messages) !== "rendered_action") return; + + captureDemoAgentInteraction({ + demo: "compare", + variant: "cloud", + model, + interaction_source: "rendered_action", + }); + }), + ); const cloudFetch = useMemo(() => { return async (input, init) => { if (typeof input !== "string" || input !== "/api/openui-cloud/frontend-token") { diff --git a/docs/app/compare/_components/agent-surfaces/oss-agent-surface.tsx b/docs/app/compare/_components/agent-surfaces/oss-agent-surface.tsx index 20ef99a7b..4b270f7b3 100644 --- a/docs/app/compare/_components/agent-surfaces/oss-agent-surface.tsx +++ b/docs/app/compare/_components/agent-surfaces/oss-agent-surface.tsx @@ -1,7 +1,13 @@ "use client"; -import { AgentInterface } from "@openuidev/react-ui"; +import { + captureDemoAgentInteraction, + getDemoInteractionSourceFromMessages, +} from "@/lib/demo-analytics"; +import { DEFAULT_MODEL } from "@/lib/openui-cloud/models"; +import { AgentInterface, type Message } from "@openuidev/react-ui"; import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib"; +import { useCallback } from "react"; import type { ComparisonControllerRegistry } from "../comparison-mode-controller"; import { ComparisonModeControllerBridge } from "../comparison-mode-controller"; import { ComparisonSurfaceWelcome } from "./comparison-surface-welcome"; @@ -13,7 +19,17 @@ interface OssAgentSurfaceProps { } export function OssAgentSurface({ onCreditsExhausted, registry }: OssAgentSurfaceProps) { - const llm = useComparisonChatLLM("openui", onCreditsExhausted); + const observeSend = useCallback((messages: readonly Message[]) => { + if (getDemoInteractionSourceFromMessages(messages) !== "rendered_action") return; + + captureDemoAgentInteraction({ + demo: "compare", + variant: "oss", + model: DEFAULT_MODEL, + interaction_source: "rendered_action", + }); + }, []); + const llm = useComparisonChatLLM("openui", onCreditsExhausted, observeSend); return (
diff --git a/docs/app/compare/_components/agent-surfaces/use-comparison-chat-llm.ts b/docs/app/compare/_components/agent-surfaces/use-comparison-chat-llm.ts index 0272e39bb..f2586c22e 100644 --- a/docs/app/compare/_components/agent-surfaces/use-comparison-chat-llm.ts +++ b/docs/app/compare/_components/agent-surfaces/use-comparison-chat-llm.ts @@ -1,7 +1,12 @@ "use client"; import { isDemoCreditsErrorPayload } from "@/lib/demo-credits"; -import { openAIAdapter, openAIMessageFormat, type ChatLLM } from "@openuidev/react-ui"; +import { + openAIAdapter, + openAIMessageFormat, + type ChatLLM, + type Message, +} from "@openuidev/react-ui"; import { useMemo } from "react"; type ComparisonResponseMode = "markdown" | "openui"; @@ -13,10 +18,17 @@ type ComparisonResponseMode = "markdown" | "openui"; export function useComparisonChatLLM( responseMode: ComparisonResponseMode, onCreditsExhausted: () => void, + onSend?: (messages: readonly Message[]) => void, ): ChatLLM { return useMemo( () => ({ send: async ({ messages, signal }) => { + try { + onSend?.(messages); + } catch { + // Analytics observers must never interfere with the chat request. + } + const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -36,7 +48,7 @@ export function useComparisonChatLLM( }, streamProtocol: openAIAdapter(), }), - [onCreditsExhausted, responseMode], + [onCreditsExhausted, onSend, responseMode], ); } diff --git a/docs/app/compare/_components/compare-page-client.tsx b/docs/app/compare/_components/compare-page-client.tsx index 5d6059246..225f549f6 100644 --- a/docs/app/compare/_components/compare-page-client.tsx +++ b/docs/app/compare/_components/compare-page-client.tsx @@ -23,7 +23,8 @@ import { getComparisonPair, type ComparisonPair, } from "./chat-types"; -import { ComparisonControls } from "./comparison-controls"; +import { captureComparisonPromptInteractions } from "./comparison-analytics"; +import { ComparisonControls, type ComparisonSubmissionSource } from "./comparison-controls"; import type { ComparisonMode, ComparisonModeController, @@ -148,15 +149,18 @@ export function ComparePageClient({ }, [registry, unavailableModes]); const submitToAll = useCallback( - (content: string) => { + (content: string, source: ComparisonSubmissionSource) => { const controllers = getReadyControllers(); if (!controllers) return; const states = controllers.map((controller) => controller.getSnapshot()); if (states.some((state) => !state.isReady || state.isRunning)) return; + const availableModes = new Set(controllers.map(({ mode }) => mode)); + const visibleModes = getComparisonPair(pair).modes.filter((mode) => availableModes.has(mode)); + captureComparisonPromptInteractions(visibleModes, source); void Promise.allSettled(controllers.map((controller) => controller.send(content))); }, - [getReadyControllers], + [getReadyControllers, pair], ); const stopAll = useCallback(() => { diff --git a/docs/app/compare/_components/comparison-analytics.ts b/docs/app/compare/_components/comparison-analytics.ts new file mode 100644 index 000000000..958674654 --- /dev/null +++ b/docs/app/compare/_components/comparison-analytics.ts @@ -0,0 +1,22 @@ +import { + captureDemoAgentInteraction, + type DemoInteractionSource, +} from "../../../lib/demo-analytics"; +import { DEFAULT_MODEL } from "../../../lib/openui-cloud/models"; +import type { ComparisonMode } from "./comparison-mode-controller"; + +export type ComparisonPromptSource = Extract; + +export function captureComparisonPromptInteractions( + modes: readonly ComparisonMode[], + interactionSource: ComparisonPromptSource, +): void { + modes.forEach((mode) => { + captureDemoAgentInteraction({ + demo: "compare", + variant: mode, + model: DEFAULT_MODEL, + interaction_source: interactionSource, + }); + }); +} diff --git a/docs/app/compare/_components/comparison-controls.tsx b/docs/app/compare/_components/comparison-controls.tsx index 6edd68063..6bd9b00cf 100644 --- a/docs/app/compare/_components/comparison-controls.tsx +++ b/docs/app/compare/_components/comparison-controls.tsx @@ -65,12 +65,14 @@ const COMPARISON_SUGGESTIONS = [ }, ] as const; +export type ComparisonSubmissionSource = "composer" | "starter"; + interface ComparisonControlsProps { comparisonPair: ComparisonPair; isReady: boolean; isRunning: boolean; hasStarted: boolean; - onSubmit: (content: string) => void; + onSubmit: (content: string, source: ComparisonSubmissionSource) => void; onStop: () => void; onReset: () => void; isDegraded: boolean; @@ -121,11 +123,11 @@ export function ComparisonControls({ return () => observer.disconnect(); }, [text]); - const submit = (content: string) => { + const submit = (content: string, source: ComparisonSubmissionSource) => { const next = content.trim(); if (!next || !isReady || isRunning) return; - onSubmit(next); + onSubmit(next, source); setText(""); }; @@ -152,7 +154,7 @@ export function ComparisonControls({ key={suggestion.label} type="button" className={styles.suggestionButton} - onClick={() => submit(suggestion.prompt)} + onClick={() => submit(suggestion.prompt, "starter")} disabled={!isReady || isRunning || (needsCloud && !cloudEnabled)} title={ needsCloud && !cloudEnabled @@ -217,14 +219,14 @@ export function ComparisonControls({ onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); - submit(text); + submit(text, "composer"); } }} />