From 42d8fd5cb03b155b79b20baa2a96ea080c266997 Mon Sep 17 00:00:00 2001 From: Andy Braren Date: Wed, 14 Jan 2026 11:03:58 -0500 Subject: [PATCH] Add per-message user feedback that gets sent to langfuse --- components/frontend/.env.example | 7 + components/frontend/package-lock.json | 34 +++ components/frontend/package.json | 1 + .../frontend/src/app/api/feedback/route.ts | 156 +++++++++++ .../[name]/sessions/[sessionName]/page.tsx | 77 +++--- .../components/feedback/FeedbackButtons.tsx | 133 ++++++++++ .../src/components/feedback/FeedbackModal.tsx | 250 ++++++++++++++++++ .../frontend/src/components/feedback/index.ts | 3 + .../frontend/src/components/ui/message.tsx | 11 +- .../src/components/ui/stream-message.tsx | 43 ++- .../frontend/src/contexts/FeedbackContext.tsx | 71 +++++ .../manifests/base/frontend-deployment.yaml | 20 ++ .../overlays/local-dev/frontend-patch.yaml | 19 ++ 13 files changed, 791 insertions(+), 34 deletions(-) create mode 100644 components/frontend/src/app/api/feedback/route.ts create mode 100644 components/frontend/src/components/feedback/FeedbackButtons.tsx create mode 100644 components/frontend/src/components/feedback/FeedbackModal.tsx create mode 100644 components/frontend/src/components/feedback/index.ts create mode 100644 components/frontend/src/contexts/FeedbackContext.tsx diff --git a/components/frontend/.env.example b/components/frontend/.env.example index 2af227336..c3e769310 100644 --- a/components/frontend/.env.example +++ b/components/frontend/.env.example @@ -31,3 +31,10 @@ MAX_UPLOAD_SIZE_DOCUMENTS=716800 MAX_UPLOAD_SIZE_IMAGES=3145728 IMAGE_COMPRESSION_TARGET=358400 + +# Langfuse Configuration for User Feedback +# These are used by the /api/feedback route to submit user feedback scores +# Get your keys from your Langfuse instance: Settings > API Keys +# LANGFUSE_HOST=https://langfuse-langfuse.apps.rosa.vteam-uat.0ksl.p3.openshiftapps.com +# LANGFUSE_PUBLIC_KEY=pk-lf-YOUR-PUBLIC-KEY-HERE +# LANGFUSE_SECRET_KEY=sk-lf-YOUR-SECRET-KEY-HERE diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index 8b9a5523e..02a06fef8 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -27,6 +27,7 @@ "date-fns": "^4.1.0", "file-type": "^21.1.1", "highlight.js": "^11.11.1", + "langfuse": "^3.38.6", "lucide-react": "^0.542.0", "next": "15.5.9", "next-themes": "^0.4.6", @@ -5933,6 +5934,30 @@ "json-buffer": "3.0.1" } }, + "node_modules/langfuse": { + "version": "3.38.6", + "resolved": "https://registry.npmjs.org/langfuse/-/langfuse-3.38.6.tgz", + "integrity": "sha512-mtwfsNGIYvObRh+NYNGlJQJDiBN+Wr3Hnr++wN25mxuOpSTdXX+JQqVCyAqGL5GD2TAXRZ7COsN42Vmp9krYmg==", + "license": "MIT", + "dependencies": { + "langfuse-core": "^3.38.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/langfuse-core": { + "version": "3.38.6", + "resolved": "https://registry.npmjs.org/langfuse-core/-/langfuse-core-3.38.6.tgz", + "integrity": "sha512-EcZXa+DK9FJdi1I30+u19eKjuBJ04du6j2Nybk19KKCuraLczg/ppkTQcGvc4QOk//OAi3qUHrajUuV74RXsBQ==", + "license": "MIT", + "dependencies": { + "mustache": "^4.2.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -7232,6 +7257,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", diff --git a/components/frontend/package.json b/components/frontend/package.json index 7f0109453..9f34e55a7 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -28,6 +28,7 @@ "date-fns": "^4.1.0", "file-type": "^21.1.1", "highlight.js": "^11.11.1", + "langfuse": "^3.38.6", "lucide-react": "^0.542.0", "next": "15.5.9", "next-themes": "^0.4.6", diff --git a/components/frontend/src/app/api/feedback/route.ts b/components/frontend/src/app/api/feedback/route.ts new file mode 100644 index 000000000..1049d255a --- /dev/null +++ b/components/frontend/src/app/api/feedback/route.ts @@ -0,0 +1,156 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * POST /api/feedback + * + * Sends user feedback to Langfuse as a score. + * This route acts as a proxy to protect the Langfuse secret key. + * + * Request body: + * - traceId: string (optional - if we have a trace ID from the session) + * - value: number (1 for positive, 0 for negative) + * - comment?: string (optional user comment) + * - username: string + * - projectName: string + * - sessionName: string + * - context?: string (what the user was working on) + * - includeTranscript?: boolean + * - transcript?: Array<{ role: string; content: string; timestamp?: string }> + */ + +type FeedbackRequest = { + traceId?: string; + value: number; + comment?: string; + username: string; + projectName: string; + sessionName: string; + context?: string; + includeTranscript?: boolean; + transcript?: Array<{ role: string; content: string; timestamp?: string }>; +}; + +export async function POST(request: NextRequest) { + try { + const body: FeedbackRequest = await request.json(); + + const { + traceId, + value, + comment, + username, + projectName, + sessionName, + context, + includeTranscript, + transcript, + } = body; + + // Validate required fields + if (typeof value !== 'number' || !username || !projectName || !sessionName) { + return NextResponse.json( + { error: 'Missing required fields: value, username, projectName, sessionName' }, + { status: 400 } + ); + } + + // Get Langfuse configuration from environment + const publicKey = process.env.LANGFUSE_PUBLIC_KEY; + const secretKey = process.env.LANGFUSE_SECRET_KEY; + const host = process.env.LANGFUSE_HOST || process.env.NEXT_PUBLIC_LANGFUSE_HOST; + + if (!publicKey || !secretKey || !host) { + console.warn('Langfuse not configured - feedback will not be recorded'); + return NextResponse.json({ + success: false, + message: 'Langfuse not configured' + }); + } + + // Build the feedback comment with context + const feedbackParts: string[] = []; + + if (comment) { + feedbackParts.push(`User Comment: ${comment}`); + } + + feedbackParts.push(`Project: ${projectName}`); + feedbackParts.push(`Session: ${sessionName}`); + feedbackParts.push(`User: ${username}`); + + if (context) { + feedbackParts.push(`Context: ${context}`); + } + + if (includeTranscript && transcript && transcript.length > 0) { + // Limit transcript to last 10 messages to avoid huge payloads + const recentMessages = transcript.slice(-10); + const transcriptSummary = recentMessages + .map(m => `[${m.role}]: ${m.content.substring(0, 200)}${m.content.length > 200 ? '...' : ''}`) + .join('\n'); + feedbackParts.push(`\nRecent Transcript:\n${transcriptSummary}`); + } + + const fullComment = feedbackParts.join('\n'); + + // Prepare the score payload for Langfuse + // If we don't have a traceId, we create a standalone score event + const scorePayload = { + name: 'user-feedback', + value: value, + comment: fullComment, + // Include metadata for filtering in Langfuse + dataType: 'NUMERIC' as const, + }; + + // If we have a traceId, attach the score to that trace + // Otherwise, we create the score and associate with session metadata + const endpoint = traceId + ? `${host}/api/public/scores` + : `${host}/api/public/scores`; + + const payload = traceId + ? { ...scorePayload, traceId } + : { + ...scorePayload, + // When no traceId, include identifying metadata + traceId: `feedback-${sessionName}-${Date.now()}`, + }; + + // Send to Langfuse API + const authHeader = Buffer.from(`${publicKey}:${secretKey}`).toString('base64'); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${authHeader}`, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Langfuse API error:', response.status, errorText); + return NextResponse.json( + { error: 'Failed to submit feedback to Langfuse' }, + { status: 500 } + ); + } + + const result = await response.json(); + + return NextResponse.json({ + success: true, + scoreId: result.id, + message: 'Feedback submitted successfully' + }); + + } catch (error) { + console.error('Error submitting feedback:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index 355b08ae1..d86052df6 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -82,6 +82,7 @@ import { useDeleteSession, useContinueSession, useReposStatus, + useCurrentUser, } from "@/services/queries"; import { useWorkspaceList, @@ -93,6 +94,7 @@ import { } from "@/services/queries/use-workflows"; import { useProjectIntegrationStatus } from "@/services/queries/use-projects"; import { useMutation } from "@tanstack/react-query"; +import { FeedbackProvider } from "@/contexts/FeedbackContext"; // Constants for artifact auto-refresh timing // Moved outside component to avoid unnecessary effect re-runs @@ -187,6 +189,9 @@ export default function ProjectSessionDetailPage({ // Check integration status const { data: integrationStatus } = useProjectIntegrationStatus(projectName); const githubConfigured = integrationStatus?.github ?? false; + + // Get current user for feedback context + const { data: currentUser } = useCurrentUser(); // Extract phase for sidebar state management const phase = session?.status?.phase || "Pending"; @@ -1966,37 +1971,47 @@ export default function ProjectSessionDetailPage({ )}
- Promise.resolve(sendChat())} - onInterrupt={aguiInterrupt} - onEndSession={() => Promise.resolve(handleEndSession())} - onGoToResults={() => {}} - onContinue={handleContinue} - workflowMetadata={workflowMetadata} - onCommandClick={handleCommandClick} - isRunActive={isRunActive} - showWelcomeExperience={!["Completed", "Failed", "Stopped", "Stopping"].includes(session?.status?.phase || "")} - activeWorkflow={workflowManagement.activeWorkflow} - userHasInteracted={userHasInteracted} - queuedMessages={sessionQueue.messages} - hasRealMessages={hasRealMessages} - welcomeExperienceComponent={ - setUserHasInteracted(true)} - userHasInteracted={userHasInteracted} - sessionPhase={session?.status?.phase} - hasRealMessages={hasRealMessages} - onLoadWorkflow={() => setCustomWorkflowDialogOpen(true)} - selectedWorkflow={workflowManagement.selectedWorkflow} - /> - } - /> + + Promise.resolve(sendChat())} + onInterrupt={aguiInterrupt} + onEndSession={() => Promise.resolve(handleEndSession())} + onGoToResults={() => {}} + onContinue={handleContinue} + workflowMetadata={workflowMetadata} + onCommandClick={handleCommandClick} + isRunActive={isRunActive} + showWelcomeExperience={!["Completed", "Failed", "Stopped", "Stopping"].includes(session?.status?.phase || "")} + activeWorkflow={workflowManagement.activeWorkflow} + userHasInteracted={userHasInteracted} + queuedMessages={sessionQueue.messages} + hasRealMessages={hasRealMessages} + welcomeExperienceComponent={ + setUserHasInteracted(true)} + userHasInteracted={userHasInteracted} + sessionPhase={session?.status?.phase} + hasRealMessages={hasRealMessages} + onLoadWorkflow={() => setCustomWorkflowDialogOpen(true)} + selectedWorkflow={workflowManagement.selectedWorkflow} + /> + } + /> +
diff --git a/components/frontend/src/components/feedback/FeedbackButtons.tsx b/components/frontend/src/components/feedback/FeedbackButtons.tsx new file mode 100644 index 000000000..42ad9bade --- /dev/null +++ b/components/frontend/src/components/feedback/FeedbackButtons.tsx @@ -0,0 +1,133 @@ +"use client"; + +import React, { useState } from "react"; +import { ThumbsUp, ThumbsDown, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { FeedbackModal, FeedbackType } from "./FeedbackModal"; +import { useFeedbackContextOptional } from "@/contexts/FeedbackContext"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +type FeedbackButtonsProps = { + messageContent?: string; + messageTimestamp?: string; + className?: string; +}; + +export function FeedbackButtons({ + messageContent, + messageTimestamp, + className, +}: FeedbackButtonsProps) { + const [feedbackModalOpen, setFeedbackModalOpen] = useState(false); + const [selectedFeedback, setSelectedFeedback] = useState(null); + const [submittedFeedback, setSubmittedFeedback] = useState(null); + + const feedbackContext = useFeedbackContextOptional(); + + // Don't render if no context available + if (!feedbackContext) { + return null; + } + + const handleFeedbackClick = (type: FeedbackType) => { + // If already submitted this feedback type, do nothing + if (submittedFeedback === type) { + return; + } + + setSelectedFeedback(type); + setFeedbackModalOpen(true); + }; + + const handleSubmitSuccess = () => { + setSubmittedFeedback(selectedFeedback); + }; + + const isPositiveSubmitted = submittedFeedback === "positive"; + const isNegativeSubmitted = submittedFeedback === "negative"; + + return ( + <> +
+ + {/* Thumbs Up Button */} + + + + + + {isPositiveSubmitted ? "Thanks for your feedback!" : "This was helpful"} + + + + {/* Thumbs Down Button */} + + + + + + {isNegativeSubmitted ? "Thanks for your feedback!" : "This wasn't helpful"} + + + +
+ + {/* Feedback Modal */} + {selectedFeedback && ( + + )} + + ); +} diff --git a/components/frontend/src/components/feedback/FeedbackModal.tsx b/components/frontend/src/components/feedback/FeedbackModal.tsx new file mode 100644 index 000000000..bca9881e5 --- /dev/null +++ b/components/frontend/src/components/feedback/FeedbackModal.tsx @@ -0,0 +1,250 @@ +"use client"; + +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { ThumbsUp, ThumbsDown, Loader2, AlertTriangle } from "lucide-react"; +import { useFeedbackContextOptional } from "@/contexts/FeedbackContext"; +import type { MessageObject, ToolUseMessages } from "@/types/agentic-session"; + +export type FeedbackType = "positive" | "negative"; + +type FeedbackModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + feedbackType: FeedbackType; + messageContent?: string; + messageTimestamp?: string; + onSubmitSuccess?: () => void; +}; + +// Helper to extract text content from messages +function extractMessageText( + messages: Array +): Array<{ role: string; content: string; timestamp?: string }> { + return messages + .filter((m): m is MessageObject => "type" in m && m.type !== undefined) + .filter((m) => m.type === "user_message" || m.type === "agent_message") + .map((m) => { + let content = ""; + if (typeof m.content === "string") { + content = m.content; + } else if ("text" in m.content) { + content = m.content.text; + } else if ("thinking" in m.content) { + content = m.content.thinking; + } + return { + role: m.type === "user_message" ? "user" : "assistant", + content, + timestamp: m.timestamp, + }; + }); +} + +export function FeedbackModal({ + open, + onOpenChange, + feedbackType, + messageContent, + onSubmitSuccess, +}: FeedbackModalProps) { + const [comment, setComment] = useState(""); + const [includeTranscript, setIncludeTranscript] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const feedbackContext = useFeedbackContextOptional(); + + const handleSubmit = async () => { + if (!feedbackContext) { + setError("Session context not available"); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + // Build context string from what the user was working on + const contextParts: string[] = []; + + if (feedbackContext.initialPrompt) { + contextParts.push(`Initial prompt: ${feedbackContext.initialPrompt.substring(0, 200)}`); + } + + if (feedbackContext.activeWorkflow) { + contextParts.push(`Workflow: ${feedbackContext.activeWorkflow}`); + } + + if (messageContent) { + contextParts.push(`Response being rated: ${messageContent.substring(0, 500)}`); + } + + const transcript = includeTranscript + ? extractMessageText(feedbackContext.messages) + : undefined; + + const response = await fetch("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + value: feedbackType === "positive" ? 1 : 0, + comment: comment || undefined, + username: feedbackContext.username, + projectName: feedbackContext.projectName, + sessionName: feedbackContext.sessionName, + context: contextParts.join("; "), + includeTranscript, + transcript, + traceId: feedbackContext.traceId, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to submit feedback"); + } + + // Success - close modal and reset + setComment(""); + setIncludeTranscript(false); + onOpenChange(false); + onSubmitSuccess?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit feedback"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + setComment(""); + setIncludeTranscript(false); + setError(null); + onOpenChange(false); + }; + + const isPositive = feedbackType === "positive"; + + return ( + + + + + {isPositive ? ( + <> + + Thanks for the positive feedback! + + ) : ( + <> + + We're sorry this wasn't helpful + + )} + + + {isPositive + ? "Help us understand what worked well." + : "Help us improve by sharing what went wrong."} + + + +
+ {/* Comment textarea */} +
+ +