mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-06 04:19:20 +00:00
docs: inkeep analytics with feedback integration (#5241)
Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
562da0cc9b
commit
8c45985dec
36
docs/app/api/analytics/conversation/route.ts
Normal file
36
docs/app/api/analytics/conversation/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
logConversationToAnalytics,
|
||||
type InkeepMessage,
|
||||
} from "@/lib/inkeep-analytics";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { messages }: { messages: InkeepMessage[] } = await req.json();
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Messages array is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await logConversationToAnalytics({
|
||||
type: "openai",
|
||||
messages,
|
||||
properties: {
|
||||
source: "better-auth-docs",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to log conversation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
35
docs/app/api/analytics/event/route.ts
Normal file
35
docs/app/api/analytics/event/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { logEventToAnalytics } from "@/lib/inkeep-analytics";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { type, entityType, messageId, conversationId } = await req.json();
|
||||
|
||||
if (!type || !entityType) {
|
||||
return NextResponse.json(
|
||||
{ error: "type and entityType are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (entityType !== "message" && entityType !== "conversation") {
|
||||
return NextResponse.json(
|
||||
{ error: "entityType must be 'message' or 'conversation'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await logEventToAnalytics({
|
||||
type,
|
||||
entityType,
|
||||
messageId,
|
||||
conversationId,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to log event" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
37
docs/app/api/analytics/feedback/route.ts
Normal file
37
docs/app/api/analytics/feedback/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { submitFeedbackToAnalytics } from "@/lib/inkeep-analytics";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { messageId, type, reasons } = await req.json();
|
||||
|
||||
if (!messageId || !type) {
|
||||
return NextResponse.json(
|
||||
{ error: "messageId and type are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type !== "positive" && type !== "negative") {
|
||||
return NextResponse.json(
|
||||
{ error: "type must be 'positive' or 'negative'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await submitFeedbackToAnalytics({
|
||||
messageId,
|
||||
type,
|
||||
reasons,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to submit feedback" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ProvideLinksToolSchema } from "@/lib/chat/inkeep-qa-schema";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { convertToModelMessages, streamText } from "ai";
|
||||
import {
|
||||
logConversationToAnalytics,
|
||||
type InkeepMessage,
|
||||
} from "@/lib/inkeep-analytics";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
@@ -24,6 +28,61 @@ export async function POST(req: Request) {
|
||||
ignoreIncompleteToolCalls: true,
|
||||
}),
|
||||
toolChoice: "auto",
|
||||
onFinish: async (event) => {
|
||||
try {
|
||||
const extractMessageContent = (msg: any): string => {
|
||||
if (typeof msg.content === "string") {
|
||||
return msg.content;
|
||||
}
|
||||
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts
|
||||
.filter((part: any) => part.type === "text")
|
||||
.map((part: any) => part.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
if (msg.text) {
|
||||
return msg.text;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const assistantMessageId =
|
||||
event.response.id ||
|
||||
`assistant_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const inkeepMessages: InkeepMessage[] = [
|
||||
...reqJson.messages
|
||||
.map((msg: any) => ({
|
||||
id:
|
||||
msg.id ||
|
||||
`msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
|
||||
role: msg.role,
|
||||
content: extractMessageContent(msg),
|
||||
}))
|
||||
.filter((msg: any) => msg.content.trim() !== ""),
|
||||
{
|
||||
id: assistantMessageId,
|
||||
role: "assistant" as const,
|
||||
content: event.text,
|
||||
},
|
||||
];
|
||||
|
||||
await logConversationToAnalytics({
|
||||
type: "openai",
|
||||
messages: inkeepMessages,
|
||||
properties: {
|
||||
source: "better-auth-docs",
|
||||
timestamp: new Date().toISOString(),
|
||||
model: "inkeep-qa-sonnet-4",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Don't fail the request if analytics logging fails
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { z } from "zod";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Markdown } from "./markdown";
|
||||
import { Presence } from "@radix-ui/react-presence";
|
||||
import { MessageFeedback } from "./message-feedback";
|
||||
|
||||
const Context = createContext<{
|
||||
open: boolean;
|
||||
@@ -286,8 +287,16 @@ function ThinkingIndicator() {
|
||||
|
||||
function Message({
|
||||
message,
|
||||
messages,
|
||||
messageIndex,
|
||||
isStreaming,
|
||||
...props
|
||||
}: { message: UIMessage } & ComponentProps<"div">) {
|
||||
}: {
|
||||
message: UIMessage;
|
||||
messages?: UIMessage[];
|
||||
messageIndex?: number;
|
||||
isStreaming?: boolean;
|
||||
} & ComponentProps<"div">) {
|
||||
let markdown = "";
|
||||
let links: z.infer<typeof ProvideLinksToolSchema>["links"] = [];
|
||||
|
||||
@@ -348,6 +357,18 @@ function Message({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message.role === "assistant" && message.id && !isStreaming && (
|
||||
<MessageFeedback
|
||||
messageId={message.id}
|
||||
userMessageId={
|
||||
messages && messageIndex !== undefined && messageIndex > 0
|
||||
? messages[messageIndex - 1]?.id
|
||||
: undefined
|
||||
}
|
||||
content={markdown}
|
||||
className="opacity-100 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -481,9 +502,27 @@ export function AISearchTrigger() {
|
||||
<div className="flex flex-col gap-4">
|
||||
{chat.messages
|
||||
.filter((msg: UIMessage) => msg.role !== "system")
|
||||
.map((item: UIMessage) => (
|
||||
<Message key={item.id} message={item} />
|
||||
))}
|
||||
.map((item: UIMessage, index: number) => {
|
||||
const filteredMessages = chat.messages.filter(
|
||||
(msg: UIMessage) => msg.role !== "system",
|
||||
);
|
||||
const isLastMessage = index === filteredMessages.length - 1;
|
||||
const isCurrentlyStreaming =
|
||||
(chat.status === "streaming" ||
|
||||
chat.status === "submitted") &&
|
||||
item.role === "assistant" &&
|
||||
isLastMessage;
|
||||
|
||||
return (
|
||||
<Message
|
||||
key={item.id}
|
||||
message={item}
|
||||
messages={filteredMessages}
|
||||
messageIndex={index}
|
||||
isStreaming={isCurrentlyStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{chat.status === "submitted" && <ThinkingIndicator />}
|
||||
</div>
|
||||
</List>
|
||||
|
||||
170
docs/components/message-feedback.tsx
Normal file
170
docs/components/message-feedback.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "fumadocs-ui/components/ui/button";
|
||||
import {
|
||||
submitFeedbackToInkeep,
|
||||
logEventToInkeep,
|
||||
} from "@/lib/inkeep-analytics";
|
||||
|
||||
interface MessageFeedbackProps {
|
||||
messageId: string;
|
||||
userMessageId?: string;
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MessageFeedback({
|
||||
messageId,
|
||||
userMessageId,
|
||||
content,
|
||||
className,
|
||||
}: MessageFeedbackProps) {
|
||||
const [feedback, setFeedback] = useState<"positive" | "negative" | null>(
|
||||
null,
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
|
||||
const [showSuccessCheckmark, setShowSuccessCheckmark] = useState<
|
||||
"positive" | "negative" | null
|
||||
>(null);
|
||||
|
||||
const handleFeedback = async (type: "positive" | "negative") => {
|
||||
if (isSubmittingFeedback || feedback === type) return;
|
||||
|
||||
const feedbackMessageId = userMessageId || messageId;
|
||||
|
||||
setIsSubmittingFeedback(true);
|
||||
|
||||
try {
|
||||
await submitFeedbackToInkeep(feedbackMessageId, type, [
|
||||
{
|
||||
label:
|
||||
type === "positive" ? "helpful_response" : "unhelpful_response",
|
||||
details:
|
||||
type === "positive"
|
||||
? "The response was helpful"
|
||||
: "The response was not helpful",
|
||||
},
|
||||
]);
|
||||
|
||||
setFeedback(type);
|
||||
setShowSuccessCheckmark(type);
|
||||
|
||||
setTimeout(() => {
|
||||
setShowSuccessCheckmark(null);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setIsSubmittingFeedback(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (copied) return;
|
||||
|
||||
const eventMessageId = userMessageId || messageId;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
|
||||
await logEventToInkeep("message:copied", "message", eventMessageId);
|
||||
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
// Silently handle error
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 mt-3 pt-2 border-t border-fd-border/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFeedback("positive")}
|
||||
disabled={isSubmittingFeedback}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: "icon-sm",
|
||||
color: feedback === "positive" ? "primary" : "ghost",
|
||||
className: cn(
|
||||
"h-7 w-7 transition-colors",
|
||||
isSubmittingFeedback && "opacity-50 cursor-not-allowed",
|
||||
feedback === "positive"
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
|
||||
: "hover:bg-fd-accent hover:text-fd-accent-foreground",
|
||||
),
|
||||
}),
|
||||
)}
|
||||
title={
|
||||
showSuccessCheckmark === "positive"
|
||||
? "Feedback submitted!"
|
||||
: "Helpful"
|
||||
}
|
||||
>
|
||||
{showSuccessCheckmark === "positive" ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600 animate-in fade-in duration-200" />
|
||||
) : (
|
||||
<ThumbsUp className="h-3.5 w-3.5 transition-all duration-200" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFeedback("negative")}
|
||||
disabled={isSubmittingFeedback}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: "icon-sm",
|
||||
color: feedback === "negative" ? "primary" : "ghost",
|
||||
className: cn(
|
||||
"h-7 w-7 transition-colors",
|
||||
isSubmittingFeedback && "opacity-50 cursor-not-allowed",
|
||||
feedback === "negative"
|
||||
? "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||
: "hover:bg-fd-accent hover:text-fd-accent-foreground",
|
||||
),
|
||||
}),
|
||||
)}
|
||||
title={
|
||||
showSuccessCheckmark === "negative"
|
||||
? "Feedback submitted!"
|
||||
: "Not helpful"
|
||||
}
|
||||
>
|
||||
{showSuccessCheckmark === "negative" ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600 animate-in fade-in duration-200" />
|
||||
) : (
|
||||
<ThumbsDown className="h-3.5 w-3.5 transition-all duration-200" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: "icon-sm",
|
||||
color: "ghost",
|
||||
className:
|
||||
"h-7 w-7 hover:bg-fd-accent hover:text-fd-accent-foreground transition-colors",
|
||||
}),
|
||||
)}
|
||||
title={copied ? "Copied!" : "Copy message"}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
docs/lib/inkeep-analytics.ts
Normal file
158
docs/lib/inkeep-analytics.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
|
||||
const INKEEP_ANALYTICS_BASE_URL = "https://api.analytics.inkeep.com";
|
||||
|
||||
export interface InkeepMessage {
|
||||
id?: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface InkeepConversation {
|
||||
id?: string;
|
||||
type: "openai";
|
||||
messages: InkeepMessage[];
|
||||
properties?: Record<string, any>;
|
||||
userProperties?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface InkeepFeedback {
|
||||
type: "positive" | "negative";
|
||||
messageId: string;
|
||||
reasons?: Array<{
|
||||
label: string;
|
||||
details?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InkeepEvent {
|
||||
type: string;
|
||||
entityType: "message" | "conversation";
|
||||
messageId?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
const apiKey =
|
||||
process.env.INKEEP_ANALYTICS_API_KEY || process.env.INKEEP_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"INKEEP_ANALYTICS_API_KEY or INKEEP_API_KEY environment variable is required",
|
||||
);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function makeAnalyticsRequest(endpoint: string, data: any) {
|
||||
const apiKey = getApiKey();
|
||||
|
||||
const { data: result, error } = await betterFetch(
|
||||
`${INKEEP_ANALYTICS_BASE_URL}${endpoint}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`Inkeep Analytics API error: ${error.status} ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function logConversationToAnalytics(
|
||||
conversation: InkeepConversation,
|
||||
) {
|
||||
return await makeAnalyticsRequest("/conversations", conversation);
|
||||
}
|
||||
|
||||
export async function submitFeedbackToAnalytics(feedback: InkeepFeedback) {
|
||||
return await makeAnalyticsRequest("/feedback", feedback);
|
||||
}
|
||||
|
||||
export async function logEventToAnalytics(event: InkeepEvent) {
|
||||
return await makeAnalyticsRequest("/events", event);
|
||||
}
|
||||
|
||||
export async function logConversationToInkeep(messages: InkeepMessage[]) {
|
||||
try {
|
||||
const { data, error } = await betterFetch("/api/analytics/conversation", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ messages }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`Failed to log conversation: ${error.status} - ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitFeedbackToInkeep(
|
||||
messageId: string,
|
||||
type: "positive" | "negative",
|
||||
reasons?: Array<{ label: string; details?: string }>,
|
||||
) {
|
||||
try {
|
||||
const { data, error } = await betterFetch("/api/analytics/feedback", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ messageId, type, reasons }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`Failed to submit feedback: ${error.status} - ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error in submitFeedbackToInkeep:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logEventToInkeep(
|
||||
type: string,
|
||||
entityType: "message" | "conversation",
|
||||
messageId?: string,
|
||||
conversationId?: string,
|
||||
) {
|
||||
try {
|
||||
const { data, error } = await betterFetch("/api/analytics/event", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ type, entityType, messageId, conversationId }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`Failed to log event: ${error.status} - ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user