diff --git a/components/frontend/.env.example b/components/frontend/.env.example index 2af227336..181961845 100644 --- a/components/frontend/.env.example +++ b/components/frontend/.env.example @@ -31,3 +31,9 @@ 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 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..08c0b40b5 --- /dev/null +++ b/components/frontend/src/app/api/feedback/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getLangfuseClient } from '@/lib/langfuseClient'; + +/** + * POST /api/feedback + * + * Sends user feedback to Langfuse as a score. + * This route builds rich context from the session and sends it to Langfuse + * using the LangfuseWeb SDK (public key only - no secret key needed). + * + * 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 + * - workflow?: string (optional - active workflow name) + * - 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; + workflow?: string; + context?: string; + includeTranscript?: boolean; + transcript?: Array<{ role: string; content: string; timestamp?: string }>; +}; + +/** + * Sanitize a string to prevent log injection attacks. + * Removes control characters that could be used to fake log entries. + */ +function sanitizeString(input: string): string { + // Remove control characters except newlines and tabs (which we'll normalize) + // This prevents log injection via carriage returns, null bytes, etc. + return input + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars except \t \n \r + .replace(/\r\n/g, '\n') // Normalize line endings + .replace(/\r/g, '\n'); +} + +export async function POST(request: NextRequest) { + try { + const body: FeedbackRequest = await request.json(); + + const { + traceId, + value, + comment, + username, + projectName, + sessionName, + workflow, + 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 } + ); + } + + // Validate value range (must be 0 or 1 for thumbs down/up) + if (value !== 0 && value !== 1) { + return NextResponse.json( + { error: 'Invalid value: must be 0 (negative) or 1 (positive)' }, + { status: 400 } + ); + } + + // Sanitize string inputs to prevent log injection + const sanitizedUsername = sanitizeString(username); + const sanitizedProjectName = sanitizeString(projectName); + const sanitizedSessionName = sanitizeString(sessionName); + const sanitizedComment = comment ? sanitizeString(comment) : undefined; + const sanitizedWorkflow = workflow ? sanitizeString(workflow) : undefined; + const sanitizedContext = context ? sanitizeString(context) : undefined; + const sanitizedTraceId = traceId ? sanitizeString(traceId) : undefined; + + // Get Langfuse client (uses public key only) + const langfuse = getLangfuseClient(); + + if (!langfuse) { + console.warn('Langfuse not configured - feedback will not be recorded'); + return NextResponse.json({ + success: false, + message: 'Langfuse not configured' + }); + } + + // Sanitize transcript entries if provided + const sanitizedTranscript = transcript?.map(m => ({ + role: sanitizeString(m.role), + content: sanitizeString(m.content), + timestamp: m.timestamp ? sanitizeString(m.timestamp) : undefined, + })); + + // Build the feedback comment (user-provided content only) + const commentParts: string[] = []; + + if (sanitizedComment) { + commentParts.push(sanitizedComment); + } + + if (sanitizedContext) { + commentParts.push(`\nMessage:\n${sanitizedContext}`); + } + + if (includeTranscript && sanitizedTranscript && sanitizedTranscript.length > 0) { + const transcriptText = sanitizedTranscript + .map(m => `[${m.role}]: ${m.content}`) + .join('\n'); + commentParts.push(`\nFull Transcript:\n${transcriptText}`); + } + + const feedbackComment = commentParts.length > 0 ? commentParts.join('\n') : undefined; + + // Determine the traceId to use + const effectiveTraceId = sanitizedTraceId || `feedback-${sanitizedSessionName}-${Date.now()}`; + + // Build metadata with structured session info + const metadata: Record = { + project: sanitizedProjectName, + session: sanitizedSessionName, + user: sanitizedUsername, + }; + + if (sanitizedWorkflow) { + metadata.workflow = sanitizedWorkflow; + } + + // Send feedback using LangfuseWeb SDK + langfuse.score({ + traceId: effectiveTraceId, + name: 'user-feedback', + value: value, + comment: feedbackComment, + metadata, + }); + + return NextResponse.json({ + success: true, + traceId: effectiveTraceId, + 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..608d3e712 --- /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..5b2cad128 --- /dev/null +++ b/components/frontend/src/components/feedback/FeedbackModal.tsx @@ -0,0 +1,230 @@ +"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, Info } 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}`); + } + + if (messageContent) { + contextParts.push(messageContent); + } + + // Extract all messages for the transcript + 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, + workflow: feedbackContext.activeWorkflow || undefined, + 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 ? ( + + ) : ( + + )} + Share feedback + + + {isPositive + ? "Help us improve by sharing what went well." + : "Help us improve by sharing what went wrong."} + + + +
+ {/* Comment textarea */} +
+ +