Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions components/frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 34 additions & 0 deletions components/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions components/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
166 changes: 166 additions & 0 deletions components/frontend/src/app/api/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 }
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
useDeleteSession,
useContinueSession,
useReposStatus,
useCurrentUser,
} from "@/services/queries";
import {
useWorkspaceList,
Expand All @@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -1966,37 +1971,47 @@ export default function ProjectSessionDetailPage({
)}

<div className="flex flex-col flex-1 overflow-hidden">
<MessagesTab
session={session}
streamMessages={streamMessages}
chatInput={chatInput}
setChatInput={setChatInput}
onSendChat={() => 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={
<WelcomeExperience
ootbWorkflows={ootbWorkflows}
onWorkflowSelect={handleWelcomeWorkflowSelect}
onUserInteraction={() => setUserHasInteracted(true)}
userHasInteracted={userHasInteracted}
sessionPhase={session?.status?.phase}
hasRealMessages={hasRealMessages}
onLoadWorkflow={() => setCustomWorkflowDialogOpen(true)}
selectedWorkflow={workflowManagement.selectedWorkflow}
/>
}
/>
<FeedbackProvider
projectName={projectName}
sessionName={sessionName}
username={currentUser?.username || currentUser?.displayName || "anonymous"}
initialPrompt={session?.spec?.initialPrompt}
activeWorkflow={workflowManagement.activeWorkflow || undefined}
messages={streamMessages}
traceId={session?.status?.sdkSessionId}
>
<MessagesTab
session={session}
streamMessages={streamMessages}
chatInput={chatInput}
setChatInput={setChatInput}
onSendChat={() => 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={
<WelcomeExperience
ootbWorkflows={ootbWorkflows}
onWorkflowSelect={handleWelcomeWorkflowSelect}
onUserInteraction={() => setUserHasInteracted(true)}
userHasInteracted={userHasInteracted}
sessionPhase={session?.status?.phase}
hasRealMessages={hasRealMessages}
onLoadWorkflow={() => setCustomWorkflowDialogOpen(true)}
selectedWorkflow={workflowManagement.selectedWorkflow}
/>
}
/>
</FeedbackProvider>
</div>
</CardContent>
</Card>
Expand Down
Loading
Loading