feat(web): wrap app in PostHog React provider with survey-based feedback
Enables useThumbSurvey for feedback buttons and sends un-redacted conversation traces on user feedback.
This commit is contained in:
parent
261f49de9b
commit
ab8906a421
@ -1,23 +1,79 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
import { trackServer } from "@/lib/telemetry";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ChatLine = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
parts?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function extractTextContent(line: ChatLine): string {
|
||||
if (line.parts) {
|
||||
return line.parts
|
||||
.filter((p) => p.type === "text" && typeof p.text === "string")
|
||||
.map((p) => p.text as string)
|
||||
.join("");
|
||||
}
|
||||
return line.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/feedback
|
||||
*
|
||||
* When a user submits thumbs up/down feedback, emit an un-redacted
|
||||
* $ai_trace event to PostHog so the full conversation is visible
|
||||
* in LLM Analytics regardless of the extension's privacy mode.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { messageId, sessionId, sentiment } = body as {
|
||||
messageId?: string;
|
||||
const { sessionId, messageId } = (await req.json()) as {
|
||||
sessionId?: string;
|
||||
sentiment?: "positive" | "negative" | null;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
if (!messageId || !sentiment) {
|
||||
if (!sessionId) {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
trackServer("survey sent", {
|
||||
$survey_id: process.env.POSTHOG_FEEDBACK_SURVEY_ID || "dench-feedback",
|
||||
$survey_response: sentiment === "positive" ? 1 : 2,
|
||||
const filePath = join(resolveWebChatDir(), `${sessionId}.jsonl`);
|
||||
if (!existsSync(filePath)) {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
const lines: ChatLine[] = readFileSync(filePath, "utf-8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => {
|
||||
try { return JSON.parse(l) as ChatLine; } catch { return null; }
|
||||
})
|
||||
.filter((m): m is ChatLine => m !== null);
|
||||
|
||||
// Include all messages up to (and including) the feedback target.
|
||||
let cutoff = lines.length;
|
||||
if (messageId) {
|
||||
const idx = lines.findIndex((m) => m.id === messageId);
|
||||
if (idx >= 0) cutoff = idx + 1;
|
||||
}
|
||||
const conversation = lines.slice(0, cutoff);
|
||||
|
||||
const inputState = conversation
|
||||
.filter((m) => m.role === "user")
|
||||
.map((m) => ({ role: "user" as const, content: extractTextContent(m) }));
|
||||
const outputState = conversation
|
||||
.filter((m) => m.role === "assistant")
|
||||
.map((m) => ({ role: "assistant" as const, content: extractTextContent(m) }));
|
||||
|
||||
trackServer("$ai_trace", {
|
||||
$ai_trace_id: sessionId,
|
||||
message_id: messageId,
|
||||
$ai_session_id: sessionId,
|
||||
$ai_span_name: "chat_session",
|
||||
$ai_input_state: inputState.length > 0 ? inputState : undefined,
|
||||
$ai_output_state: outputState.length > 0 ? outputState : undefined,
|
||||
});
|
||||
} catch {
|
||||
// Fail silently -- feedback capture should never block the user.
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { UIMessage } from "ai";
|
||||
import { useThumbSurvey } from "posthog-js/react/surveys";
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { Components } from "react-markdown";
|
||||
@ -688,58 +689,56 @@ function createMarkdownComponents(
|
||||
/* ─── Feedback buttons (thumbs up / down) ─── */
|
||||
|
||||
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY || "";
|
||||
const FEEDBACK_SURVEY_ID = "019cc021-a8bf-0000-d41d-b82956ef7e6a";
|
||||
|
||||
function FeedbackButtons({ messageId, sessionId }: { messageId: string; sessionId?: string | null }) {
|
||||
const [sentiment, setSentiment] = useState<"positive" | "negative" | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const revealTrace = useCallback((sid: string | null | undefined, mid: string) => {
|
||||
if (!sid) return;
|
||||
fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: sid, messageId: mid }),
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleFeedback = useCallback(async (value: "positive" | "negative") => {
|
||||
const next = sentiment === value ? null : value;
|
||||
setSentiment(next);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messageId, sessionId, sentiment: next }),
|
||||
});
|
||||
} catch { /* fail silently */ }
|
||||
setSubmitting(false);
|
||||
}, [sentiment, messageId, sessionId]);
|
||||
const { respond, response, triggerRef } = useThumbSurvey({
|
||||
surveyId: FEEDBACK_SURVEY_ID,
|
||||
properties: {
|
||||
$ai_trace_id: sessionId,
|
||||
message_id: messageId,
|
||||
},
|
||||
onResponse: () => revealTrace(sessionId, messageId),
|
||||
});
|
||||
|
||||
if (!POSTHOG_KEY) return null;
|
||||
|
||||
const btnBase = "p-1 rounded-md transition-colors disabled:opacity-30";
|
||||
const btnBase = "p-1 rounded-md transition-colors";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div ref={triggerRef} className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleFeedback("positive")}
|
||||
onClick={() => respond("up")}
|
||||
className={btnBase}
|
||||
style={{
|
||||
color: sentiment === "positive" ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
color: response === "up" ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
}}
|
||||
title="Good response"
|
||||
aria-label="Thumbs up"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={sentiment === "positive" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={response === "up" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 10v12" /><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleFeedback("negative")}
|
||||
onClick={() => respond("down")}
|
||||
className={btnBase}
|
||||
style={{
|
||||
color: sentiment === "negative" ? "var(--color-error, #ef4444)" : "var(--color-text-muted)",
|
||||
color: response === "down" ? "var(--color-error, #ef4444)" : "var(--color-text-muted)",
|
||||
}}
|
||||
title="Bad response"
|
||||
aria-label="Thumbs down"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={sentiment === "negative" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={response === "down" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 14V2" /><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
|
||||
</svg>
|
||||
</button>
|
||||
@ -976,7 +975,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
{!isStreaming && <FeedbackButtons messageId={message.id} sessionId={sessionId} />}
|
||||
{!isStreaming && POSTHOG_KEY && <FeedbackButtons messageId={message.id} sessionId={sessionId} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider as PHProvider } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
@ -28,14 +29,10 @@ function initPostHog(anonymousId: string) {
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function PostHogPageviewTracker({ anonymousId }: { anonymousId: string }) {
|
||||
function PageviewTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
initPostHog(anonymousId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : "");
|
||||
@ -44,3 +41,24 @@ export function PostHogPageviewTracker({ anonymousId }: { anonymousId: string })
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PostHogProvider({
|
||||
anonymousId,
|
||||
children,
|
||||
}: {
|
||||
anonymousId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
initPostHog(anonymousId);
|
||||
}, [anonymousId]);
|
||||
|
||||
if (!POSTHOG_KEY) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<PHProvider client={posthog}>
|
||||
<PageviewTracker />
|
||||
{children}
|
||||
</PHProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { getAnonymousId } from "@/lib/telemetry";
|
||||
import { PostHogPageviewTracker } from "./components/posthog-provider";
|
||||
import { PostHogProvider } from "./components/posthog-provider";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -43,9 +43,10 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<Suspense fallback={null}>
|
||||
<PostHogPageviewTracker anonymousId={getAnonymousId()} />
|
||||
<PostHogProvider anonymousId={getAnonymousId()}>
|
||||
{children}
|
||||
</PostHogProvider>
|
||||
</Suspense>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user