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:
KinfeMichael Tariku
2025-10-12 03:17:48 +03:00
committed by GitHub
parent 562da0cc9b
commit 8c45985dec
7 changed files with 538 additions and 4 deletions

View 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 },
);
}
}

View 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 });
}
}

View 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 },
);
}
}

View File

@@ -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();

View File

@@ -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>

View 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>
);
}

View 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;
}
}